Skip to content

Commit 5061401

Browse files
authored
Merge pull request #73 from dev-five-git/support-form
Support form
2 parents 08f191f + 564cd3d commit 5061401

36 files changed

Lines changed: 2792 additions & 213 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

Cargo.lock

Lines changed: 91 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,59 @@ pub struct CreateUserRequest {
158158
| `Path<T>` | Path parameters |
159159
| `Query<T>` | Query parameters |
160160
| `Json<T>` | Request body (application/json) |
161-
| `Form<T>` | Request body (form-urlencoded) |
161+
| `Form<T>` | Request body (application/x-www-form-urlencoded) |
162+
| `TypedMultipart<T>` | Request body (multipart/form-data) — typed with schema |
163+
| `Multipart` | Request body (multipart/form-data) — untyped, generic object |
162164
| `TypedHeader<T>` | Header parameters |
163165
| `State<T>` | Ignored (internal) |
164166

167+
### Multipart Form Data
168+
169+
#### Typed Multipart (Recommended)
170+
171+
Upload files using `TypedMultipart` from [`axum_typed_multipart`](https://crates.io/crates/axum_typed_multipart):
172+
173+
```rust
174+
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
175+
use tempfile::NamedTempFile;
176+
177+
#[derive(TryFromMultipart, vespera::Schema)]
178+
pub struct CreateUploadRequest {
179+
pub name: String,
180+
#[form_data(limit = "10MiB")]
181+
pub file: Option<FieldData<NamedTempFile>>,
182+
}
183+
184+
#[vespera::route(post, tags = ["uploads"])]
185+
pub async fn create_upload(
186+
TypedMultipart(req): TypedMultipart<CreateUploadRequest>,
187+
) -> Json<UploadResponse> { ... }
188+
```
189+
190+
Vespera automatically generates `multipart/form-data` content type in OpenAPI, and maps `FieldData<NamedTempFile>` to `{ "type": "string", "format": "binary" }`.
191+
192+
> **Note:** `axum` must be a direct dependency of your project (not just via vespera) because `TryFromMultipart` internally references `axum::extract::multipart::Multipart`.
193+
194+
#### Raw Multipart (Untyped)
195+
196+
For dynamic multipart handling where the fields aren't known at compile time, use axum's built-in `Multipart` extractor:
197+
198+
```rust
199+
use axum::extract::Multipart;
200+
201+
#[vespera::route(post, tags = ["uploads"])]
202+
pub async fn upload(mut multipart: Multipart) -> Json<UploadResponse> {
203+
while let Some(field) = multipart.next_field().await.unwrap() {
204+
let name = field.name().unwrap_or("unknown").to_string();
205+
let data = field.bytes().await.unwrap();
206+
// Process each field dynamically...
207+
}
208+
Json(UploadResponse { success: true })
209+
}
210+
```
211+
212+
This generates a `multipart/form-data` request body with a generic `{ "type": "object" }` schema in OpenAPI, since the fields are not statically known.
213+
165214
### Error Handling
166215

167216
```rust
@@ -347,6 +396,30 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
347396

348397
**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.
349398

399+
### Multipart Mode
400+
401+
Generate `TryFromMultipart` structs from existing types using the `multipart` keyword:
402+
403+
```rust
404+
#[derive(TryFromMultipart, vespera::Schema)]
405+
pub struct CreateUploadRequest {
406+
pub name: String,
407+
#[form_data(limit = "10MiB")]
408+
pub file: Option<FieldData<NamedTempFile>>,
409+
pub description: Option<String>,
410+
}
411+
412+
// Generates a TryFromMultipart struct (no serde derives), all fields Optional
413+
schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]);
414+
```
415+
416+
When `multipart` is enabled:
417+
- Derives `TryFromMultipart` instead of `Serialize`/`Deserialize`
418+
- Suppresses `#[serde(...)]` attributes (multipart parsing is not serde-based)
419+
- Preserves `#[form_data(...)]` attributes from source struct
420+
- Skips SeaORM relation fields (nested objects can't be represented in multipart forms)
421+
- Does not generate `From` impl
422+
350423
### Parameters
351424

352425
| Parameter | Description |
@@ -360,6 +433,7 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
360433
| `name` | Custom OpenAPI schema name: `name = "UserSchema"` |
361434
| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` |
362435
| `ignore` | Skip Schema derive (bare keyword, no value) |
436+
| `multipart` | Derive `TryFromMultipart` instead of serde (bare keyword) |
363437

364438
---
365439

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

478553
---

SKILL.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub struct User { id: u32, name: String }
4141
| `Vec<T>` | `array` + items | |
4242
| `Option<T>` | T (nullable context) | Parent marks as optional |
4343
| `HashMap<K,V>` | `object` + additionalProperties | |
44+
| `FieldData<NamedTempFile>` | `string` + `format: binary` | File upload field |
4445
| `()` | empty response | 204 No Content |
4546
| Custom struct | `$ref` | Must derive Schema |
4647

@@ -52,6 +53,8 @@ pub struct User { id: u32, name: String }
5253
| `Query<T>` | query parameters | Struct fields become params |
5354
| `Json<T>` | requestBody | application/json |
5455
| `Form<T>` | requestBody | application/x-www-form-urlencoded |
56+
| `TypedMultipart<T>` | requestBody | multipart/form-data — typed with schema |
57+
| `Multipart` | requestBody | multipart/form-data — untyped, generic object |
5558
| `State<T>` | **ignored** | Internal, not API |
5659
| `Extension<T>` | **ignored** | Internal, not API |
5760
| `TypedHeader<T>` | header parameter | |
@@ -328,6 +331,7 @@ Json(model.into()) // Easy conversion!
328331
| `rename` | Rename fields | API naming differs from model |
329332
| `rename_all` | Serde rename strategy | Different casing needed |
330333
| `add` | Add new fields | New fields not in model (breaks `From` impl) |
334+
| `multipart` | Derive `TryFromMultipart` | Multipart form-data endpoints |
331335

332336
**Avoid (Special Cases Only):**
333337

@@ -422,6 +426,52 @@ pub async fn patch_user(
422426
}
423427
```
424428

429+
### Multipart Mode (`multipart`)
430+
431+
Generate `TryFromMultipart` structs from existing multipart request types:
432+
433+
```rust
434+
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
435+
use tempfile::NamedTempFile;
436+
437+
// Base multipart struct (manually defined)
438+
#[derive(TryFromMultipart, vespera::Schema)]
439+
pub struct CreateUploadRequest {
440+
pub name: String,
441+
#[form_data(limit = "10MiB")]
442+
pub thumbnail: Option<FieldData<NamedTempFile>>,
443+
#[form_data(limit = "50MiB")]
444+
pub document: Option<FieldData<NamedTempFile>>,
445+
pub tags: Option<String>,
446+
}
447+
448+
// Derive a partial update struct via schema_type!
449+
// - Derives TryFromMultipart (not serde)
450+
// - All fields become Option<T> (partial)
451+
// - "document" field excluded
452+
// - #[form_data(limit = "10MiB")] preserved from source
453+
schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["document"]);
454+
```
455+
456+
**What `multipart` mode changes:**
457+
458+
| Aspect | Normal Mode | Multipart Mode |
459+
|--------|------------|----------------|
460+
| Derives | `Serialize`, `Deserialize` | `TryFromMultipart` |
461+
| Struct attrs | `#[serde(rename_all=...)]` | None |
462+
| Field attrs | `#[serde(...)]` preserved | `#[form_data(...)]` preserved |
463+
| Relation fields | Included (BelongsTo/HasOne) | **Skipped** (can't represent in forms) |
464+
| `From` impl | Auto-generated | **Not generated** |
465+
466+
**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.
467+
468+
**Dependencies required in your Cargo.toml:**
469+
```toml
470+
axum = "0.8" # Required: TryFromMultipart references axum internals
471+
axum_typed_multipart = "0.16" # The multipart crate
472+
tempfile = "3" # For NamedTempFile file uploads
473+
```
474+
425475
### Quick Reference
426476

427477
```rust
@@ -430,6 +480,10 @@ schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name",
430480
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
431481
schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]);
432482

483+
// ✅ MULTIPART PATTERNS
484+
schema_type!(PatchUpload from CreateUploadRequest, multipart, partial);
485+
schema_type!(SmallUpload from CreateUploadRequest, multipart, omit = ["document"]);
486+
433487
// ⚠️ USE SPARINGLY
434488
schema_type!(UserPatch from crate::models::user::Model, partial); // PATCH only
435489
schema_type!(Schema from Model, name = "UserSchema"); // Same-file only

crates/vespera/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ vespera_macro = { workspace = true }
1616
axum = "0.8"
1717
axum-extra = { version = "0.12", optional = true }
1818
chrono = { version = "0.4", features = ["serde"] }
19+
axum_typed_multipart = "0.16"
20+
tempfile = "3"
1921
serde_json = "1"
2022
tower-layer = "0.3"
2123
tower-service = "0.3"

crates/vespera/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ pub use serde_json;
2929
// This allows generated types to use chrono::DateTime without users adding chrono dependency
3030
pub use chrono;
3131

32+
// Re-export axum_typed_multipart for schema_type! multipart mode
33+
// This allows generated types to use FieldData/TryFromMultipart without users adding the dependency
34+
pub use axum_typed_multipart;
35+
36+
// Re-export tempfile for schema_type! multipart mode (NamedTempFile)
37+
pub use tempfile;
38+
3239
// Re-export axum for convenience
3340
pub mod axum {
3441
pub use axum::*;

0 commit comments

Comments
 (0)