--- 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 ```rust // 1. Main entry - vespera! macro handles everything let app = 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 ); // 2. Route handlers - MUST be pub async fn #[vespera::route(get, path = "/{id}", tags = ["users"])] pub async fn get_user(Path(id): Path) -> Json { ... } // 3. Custom types - derive Schema for OpenAPI inclusion #[derive(Serialize, Deserialize, vespera::Schema)] pub struct User { id: u32, name: String } ``` --- ## Type Mapping Reference | Rust Type | OpenAPI Schema | Notes | |-----------|----------------|-------| | `String`, `&str` | `string` | | | `i8`-`i128`, `u8`-`u128` | `integer` | | | `f32`, `f64` | `number` | | | `bool` | `boolean` | | | `Vec` | `array` + items | | | `BTreeSet`, `HashSet` | `array` + items + `uniqueItems: true` | Set types | | `Option` | T (nullable context) | Parent marks as optional | | `HashMap` | `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` | `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` | path parameter | T can be tuple or struct | | `Query` | query parameters | Struct fields become params | | `Json` | requestBody | application/json | | `Form` | requestBody | application/x-www-form-urlencoded | | `TypedMultipart` | requestBody | multipart/form-data — typed with schema | | `Multipart` | requestBody | multipart/form-data — untyped, generic object | | `State` | **ignored** | Internal, not API | | `Extension` | **ignored** | Internal, not API | | `TypedHeader` | header parameter | | | `HeaderMap` | **ignored** | Too dynamic | --- ## Route Handler Requirements ```rust // ❌ Private function - NOT discovered async fn get_users() -> Json> { ... } // ❌ Non-async function - NOT supported pub fn get_users() -> Json> { ... } // ✅ Must be pub async fn pub async fn get_users() -> Json> { ... } ``` --- ## 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}` ```rust // In src/routes/users.rs #[vespera::route(get, path = "/{id}")] pub async fn get_user(...) // → GET /users/{id} ``` --- ## Serde Integration Vespera respects serde attributes: ```rust #[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 does) bio: Option, #[serde(skip)] // ✅ Excluded from schema internal_id: u64, } ``` --- ## Debugging Tips ### Schema Not Appearing 1. Check `#[derive(Schema)]` on the type 2. Check type is used in a route handler's input/output 3. Check for generic types - all type params need Schema ```rust // Generic types need Schema on all params #[derive(Schema)] struct Paginated { // T must also derive Schema items: Vec, total: u32, } ``` ### Macro Expansion ```bash # See what vespera! generates cargo expand # Validate OpenAPI output 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? ```rust // ❌ 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"]); ``` ### Basic Syntax ```rust // 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) 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)]); // 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); ``` ### 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): ```rust // 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?** - 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: ```rust // 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. ### Auto-Generated From Impl When `add` is NOT used, `schema_type!` generates a `From` impl for easy conversion: ```rust // This: schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); // Generates: pub struct UserResponse { id, name, email, created_at } impl From 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). ### 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: ```rust // 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, // → Option> pub comments: HasMany, // → Vec pub created_at: DateTimeWithTimeZone, // → chrono::DateTime } #[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` | `Box` or `Option>` | Based on FK nullability | | `BelongsTo` | `Option>` | Always optional | | `HasMany` | `Vec` | | | `DateTimeWithTimeZone` | `vespera::chrono::DateTime` | 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`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option`. ### 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: ```rust #[derive(Serialize, vespera::Schema)] #[serde(rename_all = "camelCase")] pub struct UserInArticle { pub id: Uuid, pub name: String, pub email: String, pub profile_image: Option, } #[derive(Serialize, vespera::Schema)] #[serde(rename_all = "camelCase")] pub struct CategoryInArticle { pub id: i64, pub name: String, pub parent_category_id: Option, pub is_active: bool, pub is_menu: bool, } schema_type!( ArticleResponse from crate::models::article::Model, add = [("article_review_users": Vec)] ); 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.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 `pick`ed or `add`ed ### Complete Example ```rust // ============================================ // 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, State(db): State) -> Json { 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, Json(patch): Json, // All fields are Option ) -> Json { // Apply partial update... } ``` ### Multipart Mode (`multipart`) Generate `Multipart` structs from existing multipart request types: ```rust 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>, #[form_data(limit = "50MiB")] pub document: Option>, pub tags: Option, } // Derive a partial update struct via schema_type! // - Derives Multipart (not serde) // - All fields become Option (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:** ```toml vespera = "0.1" # Includes multipart support natively tempfile = "3" # For NamedTempFile file uploads ``` ### Quick Reference ```rust // ✅ 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! ``` --- ## Merging Multiple Vespera Apps Combine routes and OpenAPI specs from multiple apps at compile time. ### export_app! Macro Export an app for merging: ```rust // 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 JSON - `ThirdApp::router() -> Router` - Axum router ### merge Parameter Merge child apps in parent: ```rust let app = vespera!( openapi = "openapi.json", docs_url = "/docs", merge = [third::ThirdApp, other::OtherApp] ) .with_state(state); ``` **What happens:** 1. Child routers merged into parent router 2. OpenAPI specs merged (paths, schemas, tags) 3. 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 ```