An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          

# Vespera

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

[![Crates.io](https://img.shields.io/crates/v/vespera.svg)](https://crates.io/crates/vespera)
[![Documentation](https://docs.rs/vespera/badge.svg)](https://docs.rs/vespera)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/dev-five-git/vespera/CI.yml?branch=main&label=CI)](https://github.com/dev-five-git/vespera/actions)
[![Codecov](https://img.shields.io/codecov/c/github/dev-five-git/vespera)](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.