返回 Skill 列表
extension
分类: 开发与工程无需 API Key

vespera

使用Vespera构建API - 为Rust/Axum提供类似FastAPI的开发体验。涵盖路由处理器、模式推导和OpenAPI生成。

person作者: jakexiaohubgithub

Vespera Usage Guide

Vespera = FastAPI DX for Rust. Zero-config OpenAPI 3.1 generation via compile-time macro scanning.

Quick Start

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
}

Request Validation (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" }
  ]
}

Supported wrappers

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

Requirements

  • T (or the inner type of Json<T>, Form<T>, …) must implement garde::Validate<Context = ()>.
  • Derive garde::Validate and annotate fields with #[garde(...)] rules (length, email, range, pattern, custom, …).
  • Vespera's #[derive(Schema)] continues to drive the OpenAPI spec — the two derives compose cleanly on the same struct.

JNI / Binary wire integration

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.


One-Liner Server Startup (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.
  • Works on any axum::Router, including the output of Router::merge, Router::nest, or vespera!(...) itself.
  • Returns std::io::Result<()> — propagate with ? from main.

Type Mapping Reference

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

Extractor Mapping Reference

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


Route Handler Requirements

// ❌ 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>> { ... }

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}

// In src/routes/users.rs
#[vespera::route(get, path = "/{id}")]
pub async fn get_user(...) // → GET /users/{id}

Serde Integration

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

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
// Generic types need Schema on all params
#[derive(Schema)]
struct Paginated<T: Schema> {  // T must also derive Schema
    items: Vec<T>,
    total: u32,
}

Macro Expansion

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

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

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

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

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

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

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

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:

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

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:

#[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:

  • Only applies to single-value relations (HasOne / BelongsTo)
  • Must be opted in explicitly with relation_adapters = [("field", AdapterStruct)]
  • The adapter struct name is used verbatim; Vespera does not infer adapter names by convention
  • Missing explicitly named adapter structs are compile errors
  • Vespera generates local compile adapters so Option<Model>.into() works without changing the route
  • OpenAPI references the adapter DTO's own schema (UserInArticle, CategoryInArticle), honoring any #[schema(name = "...")] override
  • Single-value relations not listed in relation_adapters keep the default base-schema relation type
  • HasMany relations remain excluded by default unless explicitly picked or added

Complete Example

// ============================================
// 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 Mode (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

Quick Reference

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

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

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