https://github.com/dev-five-git/vespera
A fully automated OpenAPI engine for Axum with zero-config route and schema discovery
https://github.com/dev-five-git/vespera
Last synced: about 2 months ago
JSON representation
A fully automated OpenAPI engine for Axum with zero-config route and schema discovery
- Host: GitHub
- URL: https://github.com/dev-five-git/vespera
- Owner: dev-five-git
- Created: 2025-11-24T11:14:46.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2026-04-01T14:41:36.000Z (about 2 months ago)
- Last Synced: 2026-04-01T16:24:35.916Z (about 2 months ago)
- Language: Rust
- Size: 1.86 MB
- Stars: 25
- Watchers: 0
- Forks: 2
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- Agents: AGENTS.md
Awesome Lists containing this project
README
# Vespera
**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum.
[](https://crates.io/crates/vespera)
[](https://docs.rs/vespera)
[](LICENSE)
[](https://github.com/dev-five-git/vespera/actions)
[](https://codecov.io/gh/dev-five-git/vespera)
```rust
// 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
```toml
[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`**:
```rust
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) -> Json {
Json(User { id, name: "Alice".into() })
}
/// Create a new user
#[vespera::route(post, tags = ["users"])]
pub async fn create_user(Json(user): Json) -> Json {
Json(user)
}
```
### 3. Setup Main
**`src/main.rs`**:
```rust
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
```bash
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:
```rust
// GET /users (default method)
#[vespera::route]
pub async fn list_users() -> Json> { ... }
// POST /users
#[vespera::route(post)]
pub async fn create_user(Json(user): Json) -> Json { ... }
// GET /users/{id}
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(Path(id): Path) -> Json { ... }
// 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:
```rust
#[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, // Optional field
}
```
### Supported Extractors
| Extractor | OpenAPI Mapping |
|-----------|-----------------|
| `Path` | Path parameters |
| `Query` | Query parameters |
| `Json` | Request body (application/json) |
| `Form` | Request body (application/x-www-form-urlencoded) |
| `TypedMultipart` | Request body (multipart/form-data) — typed with schema |
| `Multipart` | Request body (multipart/form-data) — untyped, generic object |
| `TypedHeader` | Header parameters |
| `State` | Ignored (internal) |
### Multipart Form Data
#### Typed Multipart (Recommended)
Upload files using vespera's built-in `TypedMultipart` extractor:
```rust
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>,
}
#[vespera::route(post, tags = ["uploads"])]
pub async fn create_upload(
TypedMultipart(req): TypedMultipart,
) -> Json { ... }
```
Vespera automatically generates `multipart/form-data` content type in OpenAPI, and maps `FieldData` 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:
```rust
use axum::extract::Multipart;
#[vespera::route(post, tags = ["uploads"])]
pub async fn upload(mut multipart: Multipart) -> Json {
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
```rust
#[derive(Serialize, Schema)]
pub struct ApiError {
pub message: String,
}
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(Path(id): Path) -> Result, (StatusCode, Json)> {
if id == 0 {
return Err((StatusCode::NOT_FOUND, Json(ApiError { message: "Not found".into() })));
}
Ok(Json(User { id, name: "Alice".into() }))
}
```
---
## `vespera!` Macro Reference
```rust
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:
```rust
// 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
```rust
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:
```rust
// 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` are resolved correctly.
### Cross-File References
Reference structs from other files using full module paths:
```rust
// 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:
```rust
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:
```rust
// All fields become Option
schema_type!(UserPatch from User, partial);
// Only specific fields become Option
schema_type!(UserPatch from User, partial = ["name", "email"]);
```
### Serde Rename All
Apply serde rename_all strategy:
```rust
// 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.:
```rust
#[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:
```rust
// omit_default + add extra fields
schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec)]);
```
### 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`). Fields with defaults are still `required` unless they are `Option`.
### SeaORM Integration
`schema_type!` has first-class support for SeaORM models with relations:
```rust
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, // → Option>
pub comments: HasMany, // → Vec
}
// Generates Schema with proper relation types
vespera::schema_type!(Schema from Model, name = "MemoSchema");
```
**Relation Type Conversions:**
| SeaORM Type | Generated Schema Type |
|-------------|----------------------|
| `HasOne` | `Box` or `Option>` |
| `BelongsTo` | `Option>` |
| `HasMany` | `Vec` |
| `DateTimeWithTimeZone` | `chrono::DateTime` |
**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:
```rust
#[derive(vespera::Multipart, vespera::Schema)]
pub struct CreateUploadRequest {
pub name: String,
#[form_data(limit = "10MiB")]
pub file: Option>,
pub description: Option,
}
// 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.
```rust
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](https://crates.io/crates/tokio-cron-scheduler) under the hood.
### Enable Feature
```toml
[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.
```rust
// 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
```rust
// 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
```rust
let app = vespera!(docs_url = "/docs")
.with_state(AppState { db: pool });
```
### Adding Middleware
```rust
let app = vespera!(docs_url = "/docs")
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());
```
### Multiple OpenAPI Files
```rust
let app = vespera!(
openapi = ["openapi.json", "docs/api-spec.json"]
);
```
### Custom Route Folder
```rust
// 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):**
```rust
// 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:**
```rust
// 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` | `array` with items |
| `Option` | nullable T |
| `HashMap` | `object` with additionalProperties |
| `BTreeSet`, `HashSet` | `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` | `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
```bash
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](./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](https://fastapi.tiangolo.com/)'s developer experience and [Next.js](https://nextjs.org/)'s file-based routing.