Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_wQjXtABjwA9eEtbjeIDo-.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Support form, multipart","date":"2026-02-13T14:49:59.251202700Z"}
95 changes: 91 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,59 @@ pub struct CreateUserRequest {
| `Path<T>` | Path parameters |
| `Query<T>` | Query parameters |
| `Json<T>` | Request body (application/json) |
| `Form<T>` | Request body (form-urlencoded) |
| `Form<T>` | Request body (application/x-www-form-urlencoded) |
| `TypedMultipart<T>` | Request body (multipart/form-data) — typed with schema |
| `Multipart` | Request body (multipart/form-data) — untyped, generic object |
| `TypedHeader<T>` | Header parameters |
| `State<T>` | Ignored (internal) |

### Multipart Form Data

#### Typed Multipart (Recommended)

Upload files using `TypedMultipart` from [`axum_typed_multipart`](https://crates.io/crates/axum_typed_multipart):

```rust
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use tempfile::NamedTempFile;

#[derive(TryFromMultipart, vespera::Schema)]
pub struct CreateUploadRequest {
pub name: String,
#[form_data(limit = "10MiB")]
pub file: Option<FieldData<NamedTempFile>>,
}

#[vespera::route(post, tags = ["uploads"])]
pub async fn create_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" }`.

> **Note:** `axum` must be a direct dependency of your project (not just via vespera) because `TryFromMultipart` internally references `axum::extract::multipart::Multipart`.

#### 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<UploadResponse> {
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
Expand Down Expand Up @@ -347,6 +396,30 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");

**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 `TryFromMultipart` structs from existing types using the `multipart` keyword:

```rust
#[derive(TryFromMultipart, vespera::Schema)]
pub struct CreateUploadRequest {
pub name: String,
#[form_data(limit = "10MiB")]
pub file: Option<FieldData<NamedTempFile>>,
pub description: Option<String>,
}

// Generates a TryFromMultipart struct (no serde derives), all fields Optional
schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]);
```

When `multipart` is enabled:
- Derives `TryFromMultipart` 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 |
Expand All @@ -360,6 +433,7 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
| `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 `TryFromMultipart` instead of serde (bare keyword) |

---

Expand Down Expand Up @@ -473,6 +547,7 @@ This automatically:
| `Vec<T>` | `array` with items |
| `Option<T>` | nullable T |
| `HashMap<K, V>` | `object` with additionalProperties |
| `FieldData<NamedTempFile>` | `string` with `format: binary` |
| Custom struct | `$ref` to components/schemas |

---
Expand Down
54 changes: 54 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub struct User { id: u32, name: String }
| `Vec<T>` | `array` + items | |
| `Option<T>` | T (nullable context) | Parent marks as optional |
| `HashMap<K,V>` | `object` + additionalProperties | |
| `FieldData<NamedTempFile>` | `string` + `format: binary` | File upload field |
| `()` | empty response | 204 No Content |
| Custom struct | `$ref` | Must derive Schema |

Expand All @@ -52,6 +53,8 @@ pub struct User { id: u32, name: String }
| `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 | |
Expand Down Expand Up @@ -328,6 +331,7 @@ Json(model.into()) // Easy conversion!
| `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 `TryFromMultipart` | Multipart form-data endpoints |

**Avoid (Special Cases Only):**

Expand Down Expand Up @@ -422,6 +426,52 @@ pub async fn patch_user(
}
```

### Multipart Mode (`multipart`)

Generate `TryFromMultipart` structs from existing multipart request types:

```rust
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use tempfile::NamedTempFile;

// Base multipart struct (manually defined)
#[derive(TryFromMultipart, vespera::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 TryFromMultipart (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` | `TryFromMultipart` |
| 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 `#[try_from_multipart(rename_all = "...")]` as fallbacks when serde attrs are absent, ensuring OpenAPI field names match runtime multipart parsing.

**Dependencies required in your Cargo.toml:**
```toml
axum = "0.8" # Required: TryFromMultipart references axum internals
axum_typed_multipart = "0.16" # The multipart crate
tempfile = "3" # For NamedTempFile file uploads
```

### Quick Reference

```rust
Expand All @@ -430,6 +480,10 @@ schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name",
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
Expand Down
2 changes: 2 additions & 0 deletions crates/vespera/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vespera_macro = { workspace = true }
axum = "0.8"
axum-extra = { version = "0.12", optional = true }
chrono = { version = "0.4", features = ["serde"] }
axum_typed_multipart = "0.16"
tempfile = "3"
serde_json = "1"
tower-layer = "0.3"
tower-service = "0.3"
7 changes: 7 additions & 0 deletions crates/vespera/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ pub use serde_json;
// This allows generated types to use chrono::DateTime without users adding chrono dependency
pub use chrono;

// Re-export axum_typed_multipart for schema_type! multipart mode
// This allows generated types to use FieldData/TryFromMultipart without users adding the dependency
pub use axum_typed_multipart;

// Re-export tempfile for schema_type! multipart mode (NamedTempFile)
pub use tempfile;

// Re-export axum for convenience
pub mod axum {
pub use axum::*;
Expand Down
Loading