Skip to content

Commit 3fac954

Browse files
committed
Support form
1 parent 08f191f commit 3fac954

25 files changed

Lines changed: 2394 additions & 113 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,35 @@ pub struct CreateUserRequest {
159159
| `Query<T>` | Query parameters |
160160
| `Json<T>` | Request body (application/json) |
161161
| `Form<T>` | Request body (form-urlencoded) |
162+
| `TypedMultipart<T>` | Request body (multipart/form-data) |
162163
| `TypedHeader<T>` | Header parameters |
163164
| `State<T>` | Ignored (internal) |
164165

166+
### Multipart Form Data
167+
168+
Upload files using `TypedMultipart` from [`axum_typed_multipart`](https://crates.io/crates/axum_typed_multipart):
169+
170+
```rust
171+
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
172+
use tempfile::NamedTempFile;
173+
174+
#[derive(TryFromMultipart, vespera::Schema)]
175+
pub struct CreateUploadRequest {
176+
pub name: String,
177+
#[form_data(limit = "10MiB")]
178+
pub file: Option<FieldData<NamedTempFile>>,
179+
}
180+
181+
#[vespera::route(post, tags = ["uploads"])]
182+
pub async fn create_upload(
183+
TypedMultipart(req): TypedMultipart<CreateUploadRequest>,
184+
) -> Json<UploadResponse> { ... }
185+
```
186+
187+
Vespera automatically generates `multipart/form-data` content type in OpenAPI, and maps `FieldData<NamedTempFile>` to `{ "type": "string", "format": "binary" }`.
188+
189+
> **Note:** `axum` must be a direct dependency of your project (not just via vespera) because `TryFromMultipart` internally references `axum::extract::multipart::Multipart`.
190+
165191
### Error Handling
166192

167193
```rust
@@ -347,6 +373,30 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
347373

348374
**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.
349375

376+
### Multipart Mode
377+
378+
Generate `TryFromMultipart` structs from existing types using the `multipart` keyword:
379+
380+
```rust
381+
#[derive(TryFromMultipart, vespera::Schema)]
382+
pub struct CreateUploadRequest {
383+
pub name: String,
384+
#[form_data(limit = "10MiB")]
385+
pub file: Option<FieldData<NamedTempFile>>,
386+
pub description: Option<String>,
387+
}
388+
389+
// Generates a TryFromMultipart struct (no serde derives), all fields Optional
390+
schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]);
391+
```
392+
393+
When `multipart` is enabled:
394+
- Derives `TryFromMultipart` instead of `Serialize`/`Deserialize`
395+
- Suppresses `#[serde(...)]` attributes (multipart parsing is not serde-based)
396+
- Preserves `#[form_data(...)]` attributes from source struct
397+
- Skips SeaORM relation fields (nested objects can't be represented in multipart forms)
398+
- Does not generate `From` impl
399+
350400
### Parameters
351401

352402
| Parameter | Description |
@@ -360,6 +410,7 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
360410
| `name` | Custom OpenAPI schema name: `name = "UserSchema"` |
361411
| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` |
362412
| `ignore` | Skip Schema derive (bare keyword, no value) |
413+
| `multipart` | Derive `TryFromMultipart` instead of serde (bare keyword) |
363414

364415
---
365416

@@ -473,6 +524,7 @@ This automatically:
473524
| `Vec<T>` | `array` with items |
474525
| `Option<T>` | nullable T |
475526
| `HashMap<K, V>` | `object` with additionalProperties |
527+
| `FieldData<NamedTempFile>` | `string` with `format: binary` |
476528
| Custom struct | `$ref` to components/schemas |
477529

478530
---

SKILL.md

Lines changed: 53 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,7 @@ 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 (file uploads) |
5557
| `State<T>` | **ignored** | Internal, not API |
5658
| `Extension<T>` | **ignored** | Internal, not API |
5759
| `TypedHeader<T>` | header parameter | |
@@ -328,6 +330,7 @@ Json(model.into()) // Easy conversion!
328330
| `rename` | Rename fields | API naming differs from model |
329331
| `rename_all` | Serde rename strategy | Different casing needed |
330332
| `add` | Add new fields | New fields not in model (breaks `From` impl) |
333+
| `multipart` | Derive `TryFromMultipart` | Multipart form-data endpoints |
331334

332335
**Avoid (Special Cases Only):**
333336

@@ -422,6 +425,52 @@ pub async fn patch_user(
422425
}
423426
```
424427

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

427476
```rust
@@ -430,6 +479,10 @@ schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name",
430479
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
431480
schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]);
432481

482+
// ✅ MULTIPART PATTERNS
483+
schema_type!(PatchUpload from CreateUploadRequest, multipart, partial);
484+
schema_type!(SmallUpload from CreateUploadRequest, multipart, omit = ["document"]);
485+
433486
// ⚠️ USE SPARINGLY
434487
schema_type!(UserPatch from crate::models::user::Model, partial); // PATCH only
435488
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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub mod openapi {
2020
pub use vespera_core::openapi::OpenApi;
2121

2222
// Re-export macros from vespera_macro
23-
pub use vespera_macro::{Schema, export_app, route, schema, schema_type, vespera};
23+
pub use vespera_macro::{export_app, route, schema, schema_type, vespera, Schema};
2424

2525
// Re-export serde_json for merge feature (runtime spec merging)
2626
pub use serde_json;
@@ -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::*;

crates/vespera_macro/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
7979
///
8080
/// Supports `#[schema(name = "CustomName")]` attribute to set custom OpenAPI schema name.
8181
#[cfg(not(tarpaulin_include))]
82-
#[proc_macro_derive(Schema, attributes(schema))]
82+
#[proc_macro_derive(Schema, attributes(schema, serde))]
8383
pub fn derive_schema(input: TokenStream) -> TokenStream {
8484
let input = syn::parse_macro_input!(input as syn::DeriveInput);
8585
let (metadata, expanded) = schema_impl::process_derive_schema(&input);

0 commit comments

Comments
 (0)