From 3fac954322aab899015a68f1661cdad206b3ddae Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 13 Feb 2026 23:50:29 +0900 Subject: [PATCH 1/4] Support form --- .../changepack_log_wQjXtABjwA9eEtbjeIDo-.json | 1 + Cargo.lock | 95 +++- README.md | 52 ++ SKILL.md | 53 +++ crates/vespera/Cargo.toml | 2 + crates/vespera/src/lib.rs | 9 +- crates/vespera_macro/src/lib.rs | 2 +- crates/vespera_macro/src/parser/parameters.rs | 12 + .../vespera_macro/src/parser/request_body.rs | 77 +++ .../src/parser/schema/serde_attrs.rs | 121 +++++ .../src/parser/schema/type_schema.rs | 12 +- ...arse_request_body_cases@req_body_form.snap | 65 +++ ...est_body_cases@req_body_multipart_raw.snap | 65 +++ .../vespera_macro/src/schema_macro/input.rs | 39 +- crates/vespera_macro/src/schema_macro/mod.rs | 278 +++++++---- .../vespera_macro/src/schema_macro/seaorm.rs | 23 + .../vespera_macro/src/schema_macro/tests.rs | 1 + .../src/schema_macro/transformation.rs | 35 ++ examples/axum-example/Cargo.toml | 3 + examples/axum-example/openapi.json | 450 ++++++++++++++++++ examples/axum-example/src/routes/form.rs | 72 +++ examples/axum-example/src/routes/mod.rs | 2 + .../axum-example/src/routes/typed_form.rs | 138 ++++++ .../snapshots/integration_test__openapi.snap | 450 ++++++++++++++++++ openapi.json | 450 ++++++++++++++++++ 25 files changed, 2394 insertions(+), 113 deletions(-) create mode 100644 .changepacks/changepack_log_wQjXtABjwA9eEtbjeIDo-.json create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_form.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_multipart_raw.snap create mode 100644 examples/axum-example/src/routes/form.rs create mode 100644 examples/axum-example/src/routes/typed_form.rs diff --git a/.changepacks/changepack_log_wQjXtABjwA9eEtbjeIDo-.json b/.changepacks/changepack_log_wQjXtABjwA9eEtbjeIDo-.json new file mode 100644 index 0000000..0b8374b --- /dev/null +++ b/.changepacks/changepack_log_wQjXtABjwA9eEtbjeIDo-.json @@ -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"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5dbad02..e8adade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -165,11 +166,14 @@ dependencies = [ name = "axum-example" version = "0.1.0" dependencies = [ + "axum", "axum-test", + "axum_typed_multipart", "insta", "sea-orm", "serde", "serde_json", + "tempfile", "third", "tokio", "tower-http", @@ -234,6 +238,41 @@ dependencies = [ "url", ] +[[package]] +name = "axum_typed_multipart" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c8b2ee396b35396ec27f5b9aa101f77000ba842dc82549a381b74c3ae2db7e" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum_typed_multipart_macros", + "bytes", + "chrono", + "futures-core", + "futures-util", + "rust_decimal", + "tempfile", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27cefbd055910a29c4a3710016559cece5bdb4fb78ec055a1c2e9f8c61e3aa9" +dependencies = [ + "darling 0.23.0", + "heck 0.5.0", + "proc-macro-error2", + "quote", + "syn 2.0.114", + "ubyte", +] + [[package]] name = "base64" version = "0.22.1" @@ -494,8 +533,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -511,13 +560,37 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.114", ] @@ -2197,7 +2270,7 @@ version = "1.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d88ad44b6ad9788c8b9476b6b91f94c7461d1e19d39cd8ea37838b1e6ff5aa8" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", @@ -2689,6 +2762,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -3032,6 +3111,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3118,8 +3203,10 @@ version = "0.1.32" dependencies = [ "axum", "axum-extra", + "axum_typed_multipart", "chrono", "serde_json", + "tempfile", "tower-layer", "tower-service", "vespera_core", diff --git a/README.md b/README.md index 332a3ed..c7815cb 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,35 @@ pub struct CreateUserRequest { | `Query` | Query parameters | | `Json` | Request body (application/json) | | `Form` | Request body (form-urlencoded) | +| `TypedMultipart` | Request body (multipart/form-data) | | `TypedHeader` | Header parameters | | `State` | Ignored (internal) | +### Multipart Form Data + +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>, +} + +#[vespera::route(post, tags = ["uploads"])] +pub async fn create_upload( + TypedMultipart(req): TypedMultipart, +) -> Json { ... } +``` + +Vespera automatically generates `multipart/form-data` content type in OpenAPI, and maps `FieldData` 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`. + ### Error Handling ```rust @@ -347,6 +373,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>, + pub description: Option, +} + +// 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 | @@ -360,6 +410,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) | --- @@ -473,6 +524,7 @@ This automatically: | `Vec` | `array` with items | | `Option` | nullable T | | `HashMap` | `object` with additionalProperties | +| `FieldData` | `string` with `format: binary` | | Custom struct | `$ref` to components/schemas | --- diff --git a/SKILL.md b/SKILL.md index 3c17384..a024905 100644 --- a/SKILL.md +++ b/SKILL.md @@ -41,6 +41,7 @@ pub struct User { id: u32, name: String } | `Vec` | `array` + items | | | `Option` | T (nullable context) | Parent marks as optional | | `HashMap` | `object` + additionalProperties | | +| `FieldData` | `string` + `format: binary` | File upload field | | `()` | empty response | 204 No Content | | Custom struct | `$ref` | Must derive Schema | @@ -52,6 +53,7 @@ pub struct User { id: u32, name: String } | `Query` | query parameters | Struct fields become params | | `Json` | requestBody | application/json | | `Form` | requestBody | application/x-www-form-urlencoded | +| `TypedMultipart` | requestBody | multipart/form-data (file uploads) | | `State` | **ignored** | Internal, not API | | `Extension` | **ignored** | Internal, not API | | `TypedHeader` | header parameter | | @@ -328,6 +330,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):** @@ -422,6 +425,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>, + #[form_data(limit = "50MiB")] + pub document: Option>, + pub tags: Option, +} + +// Derive a partial update struct via schema_type! +// - Derives TryFromMultipart (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` | `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 @@ -430,6 +479,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 diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index d76f329..d50d61a 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -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" diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index b6463d9..ffce93a 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -20,7 +20,7 @@ pub mod openapi { pub use vespera_core::openapi::OpenApi; // Re-export macros from vespera_macro -pub use vespera_macro::{Schema, export_app, route, schema, schema_type, vespera}; +pub use vespera_macro::{export_app, route, schema, schema_type, vespera, Schema}; // Re-export serde_json for merge feature (runtime spec merging) pub use serde_json; @@ -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::*; diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 165a3ed..f018cb5 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -79,7 +79,7 @@ pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /// /// Supports `#[schema(name = "CustomName")]` attribute to set custom OpenAPI schema name. #[cfg(not(tarpaulin_include))] -#[proc_macro_derive(Schema, attributes(schema))] +#[proc_macro_derive(Schema, attributes(schema, serde))] pub fn derive_schema(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); let (metadata, expanded) = schema_impl::process_derive_schema(&input); diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index d483bed..4b4bb3d 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -250,6 +250,18 @@ pub fn parse_function_parameter( // Json extractor - this will be handled as RequestBody return None; } + "Form" => { + // Form extractor - handled as RequestBody + return None; + } + "TypedMultipart" => { + // TypedMultipart extractor - handled as RequestBody + return None; + } + "Multipart" => { + // Raw Multipart extractor - handled as RequestBody + return None; + } _ => {} } } diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 08dc086..1205184 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use syn::{FnArg, PatType, Type}; use vespera_core::route::{MediaType, RequestBody}; +use vespera_core::schema::{Schema, SchemaRef, SchemaType}; use super::schema::parse_type_to_schema_ref_with_schemas; @@ -58,6 +59,80 @@ pub fn parse_request_body( content, }); } + + // Form extractor → application/x-www-form-urlencoded request body + if ident_str == "Form" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + let schema = parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + ); + let mut content = BTreeMap::new(); + content.insert( + "application/x-www-form-urlencoded".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); + } + + // TypedMultipart extractor → multipart/form-data request body + if ident_str == "TypedMultipart" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + let schema = parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + ); + let mut content = BTreeMap::new(); + content.insert( + "multipart/form-data".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); + } + + // Raw Multipart extractor (untyped) → multipart/form-data with generic object schema + if ident_str == "Multipart" + && matches!(segment.arguments, syn::PathArguments::None) + { + let mut content = BTreeMap::new(); + content.insert( + "multipart/form-data".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new( + Schema::new(SchemaType::Object), + ))), + example: None, + examples: None, + }, + ); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); + } } if is_string_like(ty.as_ref()) { @@ -108,10 +183,12 @@ mod tests { #[rstest] #[case::json("fn test(Json(payload): Json) {}", true, "json")] + #[case::form("fn test(Form(input): Form) {}", true, "form")] #[case::string("fn test(just_string: String) {}", true, "string")] #[case::str("fn test(just_str: &str) {}", true, "str")] #[case::i32("fn test(just_i32: i32) {}", false, "i32")] #[case::vec_string("fn test(just_vec_string: Vec) {}", false, "vec_string")] + #[case::multipart_raw("fn test(multipart: Multipart) {}", true, "multipart_raw")] #[case::self_ref("fn test(&self) {}", false, "self_ref")] fn test_parse_request_body_cases( #[case] func_src: &str, diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index 9bc21b5..c03275c 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -86,6 +86,7 @@ pub(crate) fn extract_schema_name_from_entity(ty: &syn::Type) -> Option } pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) for attr in attrs { if attr.path().is_ident("serde") { // Try using parse_nested_meta for robust parsing @@ -130,10 +131,34 @@ pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { } } } + + // Fallback: check for #[try_from_multipart(rename_all = "...")] + for attr in attrs { + if attr.path().is_ident("try_from_multipart") { + let mut found_rename_all = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + } + } + None } pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) for attr in attrs { if attr.path().is_ident("serde") && let syn::Meta::List(meta_list) = &attr.meta @@ -196,6 +221,29 @@ pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { } } } + + // Fallback: check for #[form_data(field_name = "...")] + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut found_field_name = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("field_name") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_field_name = Some(s.value()); + } + Ok(()) + }); + if found_field_name.is_some() { + return found_field_name; + } + } + } + None } @@ -1639,6 +1687,79 @@ mod tests { let result = extract_flatten(&[attr]); assert!(!result, "Should not match 'flattened' as 'flatten'"); } + // ================================================================= + // MULTIPART FALLBACK TESTS (form_data / try_from_multipart) + // ================================================================= + + /// Test extract_field_rename falls back to #[form_data(field_name = "...")] + #[test] + fn test_extract_field_rename_form_data_fallback() { + let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("my_file")); + } + } + + /// Test serde rename takes priority over form_data field_name + #[test] + fn test_extract_field_rename_serde_over_form_data() { + let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("serde_name")); + } + } + + /// Test extract_field_rename with form_data but no field_name key + #[test] + fn test_extract_field_rename_form_data_no_field_name() { + let struct_src = + r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result, None); + } + } + + /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] + #[test] + fn test_extract_rename_all_try_from_multipart_fallback() { + let item: syn::ItemStruct = syn::parse_str( + r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test serde rename_all takes priority over try_from_multipart rename_all + #[test] + fn test_extract_rename_all_serde_over_try_from_multipart() { + let item: syn::ItemStruct = syn::parse_str( + r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with try_from_multipart but no rename_all key + #[test] + fn test_extract_rename_all_try_from_multipart_no_rename_all() { + let item: syn::ItemStruct = syn::parse_str( + r#"#[try_from_multipart(strict)] struct Foo;"#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } } // Tests for enum representation extraction (tag, content, untagged) diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 0a3cfec..de31ba9 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -232,9 +232,15 @@ pub(crate) fn parse_type_to_schema_ref_with_schemas( format: Some("duration".to_string()), ..Schema::string() })), - // Standard library types that should not be referenced - // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { + // File upload types (axum_typed_multipart / tempfile) + // FieldData → string with binary format + "FieldData" | "NamedTempFile" => SchemaRef::Inline(Box::new(Schema { + format: Some("binary".to_string()), + ..Schema::string() + })), + // Standard library types that should not be referenced + // Note: HashMap and BTreeMap are handled above in generic types + "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { // These are not schema types, return object schema SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) } diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_form.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_form.snap new file mode 100644 index 0000000..e494af0 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_form.snap @@ -0,0 +1,65 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "application/x-www-form-urlencoded": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_multipart_raw.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_multipart_raw.snap new file mode 100644 index 0000000..bcf2d77 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_multipart_raw.snap @@ -0,0 +1,65 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "multipart/form-data": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/schema_macro/input.rs b/crates/vespera_macro/src/schema_macro/input.rs index b8f1696..30caea2 100644 --- a/crates/vespera_macro/src/schema_macro/input.rs +++ b/crates/vespera_macro/src/schema_macro/input.rs @@ -3,9 +3,10 @@ //! Defines input structures for `schema!` and `schema_type!` macros. use syn::{ - Ident, LitStr, Token, Type, bracketed, parenthesized, + bracketed, parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, + Ident, LitStr, Token, Type, }; /// Input for the schema! macro @@ -122,6 +123,9 @@ pub struct SchemaTypeInput { /// Serde rename_all strategy (e.g., "camelCase", "snake_case", "PascalCase") /// If not specified, defaults to "camelCase" when source has no rename_all pub rename_all: Option, + /// Whether to generate a multipart/form-data struct (derives TryFromMultipart instead of serde) + /// Use `multipart` bare keyword to set this to true. + pub multipart: bool, } /// Mode for the `partial` keyword in schema_type! @@ -202,6 +206,7 @@ impl Parse for SchemaTypeInput { let mut ignore_schema = false; let mut schema_name = None; let mut rename_all = None; + let mut multipart = false; // Parse optional parameters while input.peek(Token![,]) { @@ -285,11 +290,15 @@ impl Parse for SchemaTypeInput { let rename_all_lit: LitStr = input.parse()?; rename_all = Some(rename_all_lit.value()); } + "multipart" => { + // bare `multipart` - derive TryFromMultipart instead of serde + multipart = true; + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, `partial`, `ignore`, `name`, or `rename_all`", + "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, `partial`, `ignore`, `name`, `rename_all`, or `multipart`", ident_str ), )); @@ -317,6 +326,7 @@ impl Parse for SchemaTypeInput { ignore_schema, schema_name, rename_all, + multipart, }) } } @@ -669,4 +679,29 @@ mod tests { Ok(_) => panic!("Expected error"), } } + + #[test] + fn test_parse_schema_type_input_with_multipart() { + let tokens = quote::quote!(UploadReq from CreateUploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.new_type.to_string(), "UploadReq"); + assert!(input.multipart); + } + + #[test] + fn test_parse_schema_type_input_with_multipart_and_pick() { + let tokens = + quote::quote!(UploadReq from CreateUploadRequest, multipart, pick = ["name", "file"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.multipart); + assert_eq!(input.pick.unwrap(), vec!["name", "file"]); + } + + #[test] + fn test_parse_schema_type_input_with_multipart_and_partial() { + let tokens = quote::quote!(PatchUpload from CreateUploadRequest, multipart, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.multipart); + assert!(matches!(input.partial, Some(PartialMode::All))); + } } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 62e2a2f..4a162b3 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -32,8 +32,9 @@ use seaorm::{ }; use transformation::{ build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, - extract_doc_attrs, extract_field_serde_attrs, extract_serde_attrs_without_rename_all, - filter_out_serde_rename, should_skip_field, should_wrap_in_option, + extract_doc_attrs, extract_field_serde_attrs, extract_form_data_attrs, + extract_serde_attrs_without_rename_all, filter_out_serde_rename, should_skip_field, + should_wrap_in_option, }; use type_utils::{ extract_module_path, extract_type_name, is_option_type, is_qualified_path, is_seaorm_model, @@ -245,6 +246,11 @@ pub fn generate_schema_type_code( // Check if this is a SeaORM relation type let is_relation = is_seaorm_relation_type(&field.ty); + // In multipart mode, skip ALL relation fields (multipart forms can't represent nested objects) + if input.multipart && is_relation { + continue; + } + // Get field components, applying partial wrapping if needed let original_ty = &field.ty; let should_wrap_option = should_wrap_in_option( @@ -360,58 +366,98 @@ pub fn generate_schema_type_code( let vis = &field.vis; let source_field_ident = field.ident.clone().unwrap(); - // Filter field attributes: keep serde and doc attributes, remove sea_orm and others - // This is important when using schema_type! with models from other files - // that may have ORM-specific attributes we don't want in the generated struct - let serde_field_attrs = extract_field_serde_attrs(&field.attrs); - // Extract doc attributes to carry over comments to the generated struct let doc_attrs = extract_doc_attrs(&field.attrs); - // Check if field should be renamed - if let Some(new_name) = rename_map.get(&rust_field_name) { - // Create new identifier for the field - let new_field_ident = - syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); - - // Filter out serde(rename) attributes from the serde attrs - let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); - - // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name - let json_name = - extract_field_rename(&field.attrs).unwrap_or_else(|| rust_field_name.clone()); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#filtered_attrs)* - #[serde(rename = #json_name)] - #vis #new_field_ident: #field_ty - }); - - // Track mapping: new field name <- source field name - field_mappings.push(( - new_field_ident, - source_field_ident, - should_wrap_option, - is_relation, - )); + if input.multipart { + // Multipart mode: emit form_data attrs, suppress serde attrs + let form_data_attrs = extract_form_data_attrs(&field.attrs); + + // Check if field should be renamed (rename still applies to Rust field names) + if let Some(new_name) = rename_map.get(&rust_field_name) { + let new_field_ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #new_field_ident: #field_ty + }); + + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #field_ident: #field_ty + }); + + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } } else { - // No rename, keep field with serde and doc attrs - let field_ident = field.ident.clone().unwrap(); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#serde_field_attrs)* - #vis #field_ident: #field_ty - }); - - // Track mapping: same name - field_mappings.push(( - field_ident.clone(), - field_ident, - should_wrap_option, - is_relation, - )); + // Normal (serde) mode: emit serde attrs + // Filter field attributes: keep serde and doc attributes, remove sea_orm and others + // This is important when using schema_type! with models from other files + // that may have ORM-specific attributes we don't want in the generated struct + let serde_field_attrs = extract_field_serde_attrs(&field.attrs); + + // Check if field should be renamed + if let Some(new_name) = rename_map.get(&rust_field_name) { + // Create new identifier for the field + let new_field_ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + // Filter out serde(rename) attributes from the serde attrs + let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); + + // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name + let json_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rust_field_name.clone()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#filtered_attrs)* + #[serde(rename = #json_name)] + #vis #new_field_ident: #field_ty + }); + + // Track mapping: new field name <- source field name + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + // No rename, keep field with serde and doc attrs + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#serde_field_attrs)* + #vis #field_ident: #field_ty + }); + + // Track mapping: same name + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } } } } @@ -427,7 +473,9 @@ pub fn generate_schema_type_code( } // Build derive list - let clone_derive = if input.derive_clone { + // In multipart mode, force clone = false (FieldData doesn't implement Clone) + let derive_clone = if input.multipart { false } else { input.derive_clone }; + let clone_derive = if derive_clone { quote! { Clone, } } else { quote! {} @@ -449,67 +497,93 @@ pub fn generate_schema_type_code( // Check if there are any relation fields let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); - // Generate From impl only if: - // 1. `add` is not used (can't auto-populate added fields) - // 2. There are no relation fields (relation fields don't exist on source Model) + // In multipart mode, skip From and from_model impls entirely let source_type = &input.source_type; - let from_impl = if input.add.is_none() && !has_relation_fields { - let field_assignments: Vec<_> = field_mappings - .iter() - .map(|(new_ident, source_ident, wrapped, _is_relation)| { - if *wrapped { - quote! { #new_ident: Some(source.#source_ident) } - } else { - quote! { #new_ident: source.#source_ident } - } - }) - .collect(); - - quote! { - impl From<#source_type> for #new_type_name { - fn from(source: #source_type) -> Self { - Self { - #(#field_assignments),* + let (from_impl, from_model_impl) = if input.multipart { + (quote! {}, quote! {}) + } else { + // Generate From impl only if: + // 1. `add` is not used (can't auto-populate added fields) + // 2. There are no relation fields (relation fields don't exist on source Model) + let from_impl = if input.add.is_none() && !has_relation_fields { + let field_assignments: Vec<_> = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, _is_relation)| { + if *wrapped { + quote! { #new_ident: Some(source.#source_ident) } + } else { + quote! { #new_ident: source.#source_ident } + } + }) + .collect(); + + quote! { + impl From<#source_type> for #new_type_name { + fn from(source: #source_type) -> Self { + Self { + #(#field_assignments),* + } } } } - } - } else { - quote! {} - }; + } else { + quote! {} + }; - // Generate from_model impl for SeaORM Models WITH relations - // - No relations: Use `From` trait (generated above) - // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result - let from_model_impl = if is_source_seaorm_model && input.add.is_none() && has_relation_fields { - generate_from_model_with_relations( - new_type_name, - source_type, - &field_mappings, - &relation_fields, - &source_module_path, - schema_storage, - ) - } else { - quote! {} + // Generate from_model impl for SeaORM Models WITH relations + // - No relations: Use `From` trait (generated above) + // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result + let from_model_impl = + if is_source_seaorm_model && input.add.is_none() && has_relation_fields { + generate_from_model_with_relations( + new_type_name, + source_type, + &field_mappings, + &relation_fields, + &source_module_path, + schema_storage, + ) + } else { + quote! {} + }; + + (from_impl, from_model_impl) }; // Generate the new struct (with inline types for circular relations first) - let generated_tokens = quote! { - // Inline types for circular relation references - #(#inline_type_definitions)* - - #(#struct_doc_attrs)* - #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] - #schema_name_attr - #[serde(rename_all = #effective_rename_all)] - #(#serde_attrs_without_rename_all)* - pub struct #new_type_name { - #(#field_tokens),* + let generated_tokens = if input.multipart { + // Multipart mode: derive TryFromMultipart instead of serde + // Still emit #[serde(rename_all = ...)] so Schema derive can read it for OpenAPI field naming + // (Schema derive registers `serde` as a helper attribute, so this is valid without Serialize/Deserialize) + quote! { + #(#inline_type_definitions)* + + #(#struct_doc_attrs)* + #[derive(vespera::axum_typed_multipart::TryFromMultipart, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + pub struct #new_type_name { + #(#field_tokens),* + } } + } else { + // Normal serde mode + quote! { + // Inline types for circular relation references + #(#inline_type_definitions)* + + #(#struct_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } - #from_impl - #from_model_impl + #from_impl + #from_model_impl + } }; // If custom name is provided, create metadata for direct registration diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index fa2f035..4e39e02 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -67,6 +67,29 @@ pub fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) - } "DateTimeUtc" => quote! { vespera::chrono::DateTime }, "DateTimeLocal" => quote! { vespera::chrono::DateTime }, + // axum_typed_multipart types - resolve via vespera re-exports + "FieldData" => { + // Preserve inner generic: FieldData → vespera::axum_typed_multipart::FieldData + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_args: Vec<_> = args + .args + .iter() + .map(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + let converted = + convert_seaorm_type_to_chrono(inner_ty, source_module_path); + quote! { #converted } + } else { + quote! { #arg } + } + }) + .collect(); + quote! { vespera::axum_typed_multipart::FieldData<#(#inner_args),*> } + } else { + quote! { vespera::axum_typed_multipart::FieldData } + } + } + "NamedTempFile" => quote! { vespera::tempfile::NamedTempFile }, // Not a SeaORM datetime type - resolve to absolute path if needed _ => resolve_type_to_absolute_path(ty, source_module_path), } diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index c68e86d..2206e21 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -325,6 +325,7 @@ fn test_generate_schema_type_code_preserves_struct_doc() { schema_name: None, ignore_schema: false, rename_all: None, + multipart: false, }; let struct_def = StructMetadata { name: "User".to_string(), diff --git a/crates/vespera_macro/src/schema_macro/transformation.rs b/crates/vespera_macro/src/schema_macro/transformation.rs index 8ab93dd..57e04b6 100644 --- a/crates/vespera_macro/src/schema_macro/transformation.rs +++ b/crates/vespera_macro/src/schema_macro/transformation.rs @@ -122,6 +122,17 @@ pub fn extract_field_serde_attrs(attrs: &[syn::Attribute]) -> Vec<&syn::Attribut .collect() } +/// Extracts `#[form_data(...)]` attributes from a field. +/// +/// Used in multipart mode to preserve form_data attributes from the source struct +/// on generated fields (e.g., `#[form_data(limit = "10MiB")]`). +pub fn extract_form_data_attrs(attrs: &[syn::Attribute]) -> Vec<&syn::Attribute> { + attrs + .iter() + .filter(|attr| attr.path().is_ident("form_data")) + .collect() +} + /// Filters out serde(rename) attributes from a list of serde attributes. /// /// Used when applying a custom rename to avoid conflicts. @@ -384,6 +395,30 @@ mod tests { )); // relation } + #[test] + fn test_extract_form_data_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[form_data(limit = "10MiB")]), + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Some doc"]), + syn::parse_quote!(#[form_data(field_name = "my_file")]), + ]; + + let form_data = extract_form_data_attrs(&attrs); + assert_eq!(form_data.len(), 2); + } + + #[test] + fn test_extract_form_data_attrs_empty() { + let attrs: Vec = vec![ + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Some doc"]), + ]; + + let form_data = extract_form_data_attrs(&attrs); + assert!(form_data.is_empty()); + } + #[test] fn test_should_wrap_in_option_partial_fields() { let partial_set: HashSet = ["name".to_string()].into_iter().collect(); diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 2e5b79e..2137361 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -6,11 +6,14 @@ publish = false [dependencies] vespera = { path = "../../crates/vespera" } +axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } sea-orm = { version = "^2.0.0-rc.30", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } +axum_typed_multipart = "0.16" +tempfile = "3" third = { path = "../third" } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index fb20e48..e64b5fe 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -507,6 +507,99 @@ } } }, + "/form": { + "post": { + "operationId": "subscribe", + "tags": [ + "form" + ], + "description": "Subscribe to newsletter via form submission", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SubscribeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribeResponse" + } + } + } + } + } + } + }, + "/form/contact": { + "post": { + "operationId": "contact", + "tags": [ + "form" + ], + "description": "Submit a contact form", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ContactFormRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, + "/form/upload": { + "post": { + "operationId": "upload", + "tags": [ + "form" + ], + "description": "Upload a file via raw multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, "/generic/generic/{value}": { "get": { "operationId": "generic_endpoint", @@ -1267,6 +1360,169 @@ } } }, + "/typed-form": { + "get": { + "operationId": "list_file_uploads", + "tags": [ + "typed-form" + ], + "description": "List all file uploads", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_file_upload", + "tags": [ + "typed-form" + ], + "description": "Create a new file upload with multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/typed-form/{id}": { + "put": { + "operationId": "update_file_upload", + "tags": [ + "typed-form" + ], + "description": "Update a file upload with multipart form data", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UpdateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "patch": { + "operationId": "patch_file_upload", + "tags": [ + "typed-form" + ], + "description": "Patch a file upload (partial update via schema_type! multipart)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1785,6 +2041,44 @@ "nestedStructMapArray" ] }, + "ContactFormRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name", + "email", + "message" + ] + }, + "ContactFormResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "ticketId": { + "type": "string" + } + }, + "required": [ + "success", + "ticketId" + ] + }, "ContactResponse": { "type": "object", "properties": { @@ -1828,6 +2122,31 @@ "createdAt" ] }, + "CreateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "name": { + "type": "string" + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + }, + "required": [ + "name" + ] + }, "CreateMemoRequest": { "type": "object", "properties": { @@ -2200,6 +2519,44 @@ } ] }, + "FileUploadResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "documentUrl": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "isActive": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "thumbnailUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "tags", + "isActive", + "createdAt" + ] + }, "GenericStruct": { "type": "object", "properties": { @@ -2600,6 +2957,28 @@ } } }, + "PatchFileUploadRequest": { + "type": "object", + "properties": { + "isActive": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "ResponseMeta": { "type": "object", "description": "Common metadata for responses", @@ -2818,6 +3197,44 @@ "age" ] }, + "SubscribeRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "SubscribeResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "isSubscribed": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "isSubscribed" + ] + }, "TestStruct": { "type": "object", "properties": { @@ -2885,6 +3302,33 @@ } ] }, + "UpdateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "is_active": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "UpdateMemoRequest": { "type": "object", "properties": { @@ -3087,9 +3531,15 @@ { "name": "flatten" }, + { + "name": "form" + }, { "name": "hello" }, + { + "name": "typed-form" + }, { "name": "third" } diff --git a/examples/axum-example/src/routes/form.rs b/examples/axum-example/src/routes/form.rs new file mode 100644 index 0000000..cb504e1 --- /dev/null +++ b/examples/axum-example/src/routes/form.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use vespera::axum::Json; +use vespera::axum::extract::Form; +use vespera::axum::extract::Multipart; +use vespera::{Schema, route}; + +// ============== Request/Response DTOs ============== + +#[derive(Deserialize, Schema)] +pub struct SubscribeRequest { + pub name: String, + pub email: String, +} + +#[derive(Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeResponse { + pub id: i64, + pub name: String, + pub email: String, + pub is_subscribed: bool, +} + +#[derive(Deserialize, Schema)] +pub struct ContactFormRequest { + pub name: String, + pub email: String, + pub subject: Option, + pub message: String, +} + +#[derive(Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ContactFormResponse { + pub success: bool, + pub ticket_id: String, +} + +// ============== Handlers ============== + +/// Subscribe to newsletter via form submission +#[route(post, tags = ["form"])] +pub async fn subscribe(Form(input): Form) -> Json { + Json(SubscribeResponse { + id: 1, + name: input.name, + email: input.email, + is_subscribed: true, + }) +} + +/// Submit a contact form +#[route(post, path = "/contact", tags = ["form"])] +pub async fn contact(Form(input): Form) -> Json { + Json(ContactFormResponse { + success: true, + ticket_id: format!("TICKET-{}", input.name.len() + input.message.len()), + }) +} + +/// Upload a file via raw multipart form data +#[route(post, path = "/upload", tags = ["form"])] +pub async fn upload(mut multipart: Multipart) -> Json { + while let Some(field) = multipart.next_field().await.unwrap() { + let _name = field.name().unwrap_or("unknown").to_string(); + let _data = field.bytes().await.unwrap(); + } + Json(ContactFormResponse { + success: true, + ticket_id: "UPLOAD-001".to_string(), + }) +} diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index de0ade6..940a591 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -12,10 +12,12 @@ pub mod enums; pub mod error; pub mod flatten; pub mod foo; +pub mod form; pub mod generic; pub mod health; pub mod memos; pub mod path; +pub mod typed_form; pub mod typed_header; pub mod users; diff --git a/examples/axum-example/src/routes/typed_form.rs b/examples/axum-example/src/routes/typed_form.rs new file mode 100644 index 0000000..d6e00ea --- /dev/null +++ b/examples/axum-example/src/routes/typed_form.rs @@ -0,0 +1,138 @@ +use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; +use serde::Serialize; +use tempfile::NamedTempFile; +use vespera::axum::Json; +use vespera::axum::http::StatusCode; +use vespera::{Schema, route}; + +// ============== Request/Response DTOs ============== + +#[derive(Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct FileUploadResponse { + pub id: i64, + pub name: String, + pub thumbnail_url: Option, + pub document_url: Option, + pub tags: Vec, + pub is_active: bool, + pub created_at: String, +} + +#[derive(Debug, TryFromMultipart, Schema)] +pub struct CreateFileUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub thumbnail: Option>, + #[form_data(limit = "50MiB")] + pub document: Option>, + pub tags: Option, +} + +#[derive(Debug, TryFromMultipart, Schema)] +pub struct UpdateFileUploadRequest { + pub name: Option, + #[form_data(limit = "10MiB")] + pub thumbnail: Option>, + #[form_data(limit = "50MiB")] + pub document: Option>, + pub tags: Option, + pub is_active: Option, +} + +// Generated via schema_type! with multipart: derives TryFromMultipart + Schema, +// partial makes all fields Option, omits the "document" field, preserves form_data attrs. +// Note: multipart automatically sets clone = false (FieldData doesn't implement Clone). +vespera::schema_type!(PatchFileUploadRequest from UpdateFileUploadRequest, multipart, partial, omit = ["document"]); + +// ============== Handlers ============== + +/// List all file uploads +#[route(get, tags = ["typed-form"])] +pub async fn list_file_uploads() -> Json> { + Json(vec![FileUploadResponse { + id: 1, + name: "Sample Upload".to_string(), + thumbnail_url: Some("https://example.com/thumb.jpg".to_string()), + document_url: Some("https://example.com/doc.pdf".to_string()), + tags: vec!["sample".to_string(), "test".to_string()], + is_active: true, + created_at: "2024-01-01T00:00:00Z".to_string(), + }]) +} + +/// Create a new file upload with multipart form data +#[route(post, tags = ["typed-form"])] +pub async fn create_file_upload( + TypedMultipart(req): TypedMultipart, +) -> Result, (StatusCode, String)> { + let tags: Vec = req + .tags + .map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + + Ok(Json(FileUploadResponse { + id: 1, + name: req.name, + thumbnail_url: req.thumbnail.map(|_| "uploaded_thumbnail_url".to_string()), + document_url: req.document.map(|_| "uploaded_document_url".to_string()), + tags, + is_active: true, + created_at: "2024-01-01T00:00:00Z".to_string(), + })) +} + +/// Update a file upload with multipart form data +#[route(put, path = "/{id}", tags = ["typed-form"])] +pub async fn update_file_upload( + vespera::axum::extract::Path(id): vespera::axum::extract::Path, + TypedMultipart(req): TypedMultipart, +) -> Result, (StatusCode, String)> { + Ok(Json(FileUploadResponse { + id, + name: req.name.unwrap_or_else(|| "Unchanged".to_string()), + thumbnail_url: req.thumbnail.map(|_| "updated_thumbnail_url".to_string()), + document_url: req.document.map(|_| "updated_document_url".to_string()), + tags: req + .tags + .map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(), + is_active: req.is_active.unwrap_or(true), + created_at: "2024-01-01T00:00:00Z".to_string(), + })) +} + +/// Patch a file upload (partial update via schema_type! multipart) +#[route(patch, path = "/{id}", tags = ["typed-form"])] +pub async fn patch_file_upload( + vespera::axum::extract::Path(id): vespera::axum::extract::Path, + TypedMultipart(req): TypedMultipart, +) -> Result, (StatusCode, String)> { + Ok(Json(FileUploadResponse { + id, + name: req.name.unwrap_or_else(|| "Unchanged".to_string()), + thumbnail_url: req.thumbnail.map(|_| "patched_thumbnail_url".to_string()), + document_url: None, + tags: req + .tags + .map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(), + is_active: true, + created_at: "2024-01-01T00:00:00Z".to_string(), + })) +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 3908660..8ff8182 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -511,6 +511,99 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/form": { + "post": { + "operationId": "subscribe", + "tags": [ + "form" + ], + "description": "Subscribe to newsletter via form submission", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SubscribeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribeResponse" + } + } + } + } + } + } + }, + "/form/contact": { + "post": { + "operationId": "contact", + "tags": [ + "form" + ], + "description": "Submit a contact form", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ContactFormRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, + "/form/upload": { + "post": { + "operationId": "upload", + "tags": [ + "form" + ], + "description": "Upload a file via raw multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, "/generic/generic/{value}": { "get": { "operationId": "generic_endpoint", @@ -1271,6 +1364,169 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/typed-form": { + "get": { + "operationId": "list_file_uploads", + "tags": [ + "typed-form" + ], + "description": "List all file uploads", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_file_upload", + "tags": [ + "typed-form" + ], + "description": "Create a new file upload with multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/typed-form/{id}": { + "put": { + "operationId": "update_file_upload", + "tags": [ + "typed-form" + ], + "description": "Update a file upload with multipart form data", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UpdateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "patch": { + "operationId": "patch_file_upload", + "tags": [ + "typed-form" + ], + "description": "Patch a file upload (partial update via schema_type! multipart)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1789,6 +2045,44 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nestedStructMapArray" ] }, + "ContactFormRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name", + "email", + "message" + ] + }, + "ContactFormResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "ticketId": { + "type": "string" + } + }, + "required": [ + "success", + "ticketId" + ] + }, "ContactResponse": { "type": "object", "properties": { @@ -1832,6 +2126,31 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "createdAt" ] }, + "CreateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "name": { + "type": "string" + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + }, + "required": [ + "name" + ] + }, "CreateMemoRequest": { "type": "object", "properties": { @@ -2204,6 +2523,44 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } ] }, + "FileUploadResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "documentUrl": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "isActive": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "thumbnailUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "tags", + "isActive", + "createdAt" + ] + }, "GenericStruct": { "type": "object", "properties": { @@ -2604,6 +2961,28 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "PatchFileUploadRequest": { + "type": "object", + "properties": { + "isActive": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "ResponseMeta": { "type": "object", "description": "Common metadata for responses", @@ -2822,6 +3201,44 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "age" ] }, + "SubscribeRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "SubscribeResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "isSubscribed": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "isSubscribed" + ] + }, "TestStruct": { "type": "object", "properties": { @@ -2889,6 +3306,33 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } ] }, + "UpdateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "is_active": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "UpdateMemoRequest": { "type": "object", "properties": { @@ -3091,9 +3535,15 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" { "name": "flatten" }, + { + "name": "form" + }, { "name": "hello" }, + { + "name": "typed-form" + }, { "name": "third" } diff --git a/openapi.json b/openapi.json index fb20e48..e64b5fe 100644 --- a/openapi.json +++ b/openapi.json @@ -507,6 +507,99 @@ } } }, + "/form": { + "post": { + "operationId": "subscribe", + "tags": [ + "form" + ], + "description": "Subscribe to newsletter via form submission", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SubscribeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribeResponse" + } + } + } + } + } + } + }, + "/form/contact": { + "post": { + "operationId": "contact", + "tags": [ + "form" + ], + "description": "Submit a contact form", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ContactFormRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, + "/form/upload": { + "post": { + "operationId": "upload", + "tags": [ + "form" + ], + "description": "Upload a file via raw multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactFormResponse" + } + } + } + } + } + } + }, "/generic/generic/{value}": { "get": { "operationId": "generic_endpoint", @@ -1267,6 +1360,169 @@ } } }, + "/typed-form": { + "get": { + "operationId": "list_file_uploads", + "tags": [ + "typed-form" + ], + "description": "List all file uploads", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_file_upload", + "tags": [ + "typed-form" + ], + "description": "Create a new file upload with multipart form data", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/typed-form/{id}": { + "put": { + "operationId": "update_file_upload", + "tags": [ + "typed-form" + ], + "description": "Update a file upload with multipart form data", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UpdateFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "patch": { + "operationId": "patch_file_upload", + "tags": [ + "typed-form" + ], + "description": "Patch a file upload (partial update via schema_type! multipart)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchFileUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/typed-header": { "get": { "operationId": "typed_header_jwt", @@ -1785,6 +2041,44 @@ "nestedStructMapArray" ] }, + "ContactFormRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name", + "email", + "message" + ] + }, + "ContactFormResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "ticketId": { + "type": "string" + } + }, + "required": [ + "success", + "ticketId" + ] + }, "ContactResponse": { "type": "object", "properties": { @@ -1828,6 +2122,31 @@ "createdAt" ] }, + "CreateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "name": { + "type": "string" + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + }, + "required": [ + "name" + ] + }, "CreateMemoRequest": { "type": "object", "properties": { @@ -2200,6 +2519,44 @@ } ] }, + "FileUploadResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "documentUrl": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "isActive": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "thumbnailUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "tags", + "isActive", + "createdAt" + ] + }, "GenericStruct": { "type": "object", "properties": { @@ -2600,6 +2957,28 @@ } } }, + "PatchFileUploadRequest": { + "type": "object", + "properties": { + "isActive": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "ResponseMeta": { "type": "object", "description": "Common metadata for responses", @@ -2818,6 +3197,44 @@ "age" ] }, + "SubscribeRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "SubscribeResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "isSubscribed": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "isSubscribed" + ] + }, "TestStruct": { "type": "object", "properties": { @@ -2885,6 +3302,33 @@ } ] }, + "UpdateFileUploadRequest": { + "type": "object", + "properties": { + "document": { + "type": "string", + "format": "binary", + "nullable": true + }, + "is_active": { + "type": "boolean", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "nullable": true + }, + "thumbnail": { + "type": "string", + "format": "binary", + "nullable": true + } + } + }, "UpdateMemoRequest": { "type": "object", "properties": { @@ -3087,9 +3531,15 @@ { "name": "flatten" }, + { + "name": "form" + }, { "name": "hello" }, + { + "name": "typed-form" + }, { "name": "third" } From 6a6e95b271fe03561c961269afe30746bc46cbe4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 13 Feb 2026 23:52:23 +0900 Subject: [PATCH 2/4] Support form --- README.md | 27 +++++++++++++++++++++++++-- SKILL.md | 3 ++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c7815cb..dc88fd8 100644 --- a/README.md +++ b/README.md @@ -158,13 +158,16 @@ pub struct CreateUserRequest { | `Path` | Path parameters | | `Query` | Query parameters | | `Json` | Request body (application/json) | -| `Form` | Request body (form-urlencoded) | -| `TypedMultipart` | Request body (multipart/form-data) | +| `Form` | Request body (application/x-www-form-urlencoded) | +| `TypedMultipart` | Request body (multipart/form-data) — typed with schema | +| `Multipart` | Request body (multipart/form-data) — untyped, generic object | | `TypedHeader` | Header parameters | | `State` | Ignored (internal) | ### Multipart Form Data +#### Typed Multipart (Recommended) + Upload files using `TypedMultipart` from [`axum_typed_multipart`](https://crates.io/crates/axum_typed_multipart): ```rust @@ -188,6 +191,26 @@ Vespera automatically generates `multipart/form-data` content type in OpenAPI, a > **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 { + 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 diff --git a/SKILL.md b/SKILL.md index a024905..0d3bcf2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -53,7 +53,8 @@ pub struct User { id: u32, name: String } | `Query` | query parameters | Struct fields become params | | `Json` | requestBody | application/json | | `Form` | requestBody | application/x-www-form-urlencoded | -| `TypedMultipart` | requestBody | multipart/form-data (file uploads) | +| `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 | | From bfef91345db6d9a68de031b8ce11a9a5be7138d2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 14 Feb 2026 00:05:14 +0900 Subject: [PATCH 3/4] Fix lint --- examples/axum-example/src/routes/form.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/axum-example/src/routes/form.rs b/examples/axum-example/src/routes/form.rs index cb504e1..46b550f 100644 --- a/examples/axum-example/src/routes/form.rs +++ b/examples/axum-example/src/routes/form.rs @@ -21,6 +21,7 @@ pub struct SubscribeResponse { pub is_subscribed: bool, } +#[allow(dead_code)] #[derive(Deserialize, Schema)] pub struct ContactFormRequest { pub name: String, From 564cd3dff5a40850034f1c6c4565375d5892376a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 14 Feb 2026 00:21:03 +0900 Subject: [PATCH 4/4] Fix coverage --- crates/vespera/src/lib.rs | 2 +- crates/vespera_macro/src/parser/operation.rs | 12 +- crates/vespera_macro/src/parser/parameters.rs | 22 ++- .../vespera_macro/src/parser/request_body.rs | 14 +- crates/vespera_macro/src/parser/response.rs | 10 +- .../src/parser/schema/enum_schema.rs | 8 +- .../src/parser/schema/serde_attrs.rs | 33 ++-- .../src/parser/schema/type_schema.rs | 24 +-- ...tion_parameter_cases@params_form_body.snap | 5 + ...meter_cases@params_raw_multipart_body.snap | 5 + ...ter_cases@params_typed_multipart_body.snap | 5 + ...t_body_cases@req_body_typed_multipart.snap | 65 +++++++ .../src/schema_macro/circular.rs | 4 +- .../src/schema_macro/file_lookup.rs | 24 +-- .../src/schema_macro/from_model.rs | 59 ++++--- .../src/schema_macro/inline_types.rs | 12 +- .../vespera_macro/src/schema_macro/input.rs | 3 +- crates/vespera_macro/src/schema_macro/mod.rs | 6 +- .../vespera_macro/src/schema_macro/seaorm.rs | 68 +++++++- .../vespera_macro/src/schema_macro/tests.rs | 164 +++++++++++++++--- 20 files changed, 409 insertions(+), 136 deletions(-) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_form_body.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_raw_multipart_body.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_multipart_body.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_typed_multipart.snap diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index ffce93a..4c9b734 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -20,7 +20,7 @@ pub mod openapi { pub use vespera_core::openapi::OpenApi; // Re-export macros from vespera_macro -pub use vespera_macro::{export_app, route, schema, schema_type, vespera, Schema}; +pub use vespera_macro::{Schema, export_app, route, schema, schema_type, vespera}; // Re-export serde_json for merge feature (runtime spec merging) pub use serde_json; diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 58901fe..45170d5 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -487,7 +487,7 @@ mod tests { #[test] fn test_single_path_param_with_single_type() { - // Test line 55: Path with single type (not tuple) and exactly ONE path param + // Test: Path with single type // This exercises the branch: path_params.len() == 1 with non-tuple type let op = build("fn get(Path(id): Path) -> String", "/users/{id}", None); @@ -515,7 +515,7 @@ mod tests { #[test] fn test_non_path_extractor_with_query() { - // Test lines 85, 89: non-Path extractor handling + // Test: non-Path extractor handling // When input is Query, it should NOT be treated as Path let op = build( "fn search(Query(params): Query) -> String", @@ -523,7 +523,7 @@ mod tests { None, ); - // Query params should be extended to parameters (line 89) + // Test: Query params should be extended to parameters // But QueryParams is not in known_schemas/struct_definitions so it won't appear // The key is that it doesn't treat Query as a Path extractor (line 85 returns false) assert!(op.request_body.is_none()); // Query is not a body @@ -531,7 +531,7 @@ mod tests { #[test] fn test_non_path_extractor_with_state() { - // Test lines 85, 89: State should be ignored (not Path) + // Test: State should be ignored let op = build( "fn handler(State(state): State) -> String", "/handler", @@ -586,7 +586,7 @@ mod tests { #[test] fn test_multiple_path_params_with_single_type() { - // Test line 57-60: multiple path params but single type - uses type for all + // Test: multiple path params but single type let op = build( "fn get(Path(id): Path) -> String", "/shops/{shop_id}/items/{item_id}", @@ -634,7 +634,7 @@ mod tests { #[test] fn test_non_path_extractor_generates_params_and_extends() { - // Test lines 85, 89: non-Path extractor that DOES generate params + // Test: non-Path extractor that generates params // Query where T is a known struct generates query parameters let sig: syn::Signature = syn::parse_str("fn search(Query(params): Query, TypedHeader(auth): TypedHeader) -> String").unwrap(); diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 4b4bb3d..2428e2e 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -580,6 +580,24 @@ mod tests { vec![vec![ParameterLocation::Header]], "header_custom" )] + #[case( + "fn test(input: Form) {}", + vec![], + vec![vec![]], + "form_body" + )] + #[case( + "fn test(upload: TypedMultipart) {}", + vec![], + vec![vec![]], + "typed_multipart_body" + )] + #[case( + "fn test(multipart: Multipart) {}", + vec![], + vec![vec![]], + "raw_multipart_body" + )] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, @@ -901,7 +919,7 @@ mod tests { }; let ty = Type::Path(type_path); - // This MUST hit line 209 because path.segments.is_empty() is true + // Tests: path.segments.is_empty() is true assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); } @@ -941,7 +959,7 @@ mod tests { }; let ty = Type::Path(type_path); - // This MUST hit line 245 because path.segments.is_empty() is true + // Tests: path.segments.is_empty() is true let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); assert!( result.is_none(), diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 1205184..d2103de 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -113,16 +113,15 @@ pub fn parse_request_body( } // Raw Multipart extractor (untyped) → multipart/form-data with generic object schema - if ident_str == "Multipart" - && matches!(segment.arguments, syn::PathArguments::None) + if ident_str == "Multipart" && matches!(segment.arguments, syn::PathArguments::None) { let mut content = BTreeMap::new(); content.insert( "multipart/form-data".to_string(), MediaType { - schema: Some(SchemaRef::Inline(Box::new( - Schema::new(SchemaType::Object), - ))), + schema: Some(SchemaRef::Inline(Box::new(Schema::new( + SchemaType::Object, + )))), example: None, examples: None, }, @@ -188,6 +187,11 @@ mod tests { #[case::str("fn test(just_str: &str) {}", true, "str")] #[case::i32("fn test(just_i32: i32) {}", false, "i32")] #[case::vec_string("fn test(just_vec_string: Vec) {}", false, "vec_string")] + #[case::typed_multipart( + "fn test(TypedMultipart(req): TypedMultipart) {}", + true, + "typed_multipart" + )] #[case::multipart_raw("fn test(multipart: Multipart) {}", true, "multipart_raw")] #[case::self_ref("fn test(&self) {}", false, "self_ref")] fn test_parse_request_body_cases( diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 22a49d2..dab689d 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -464,7 +464,7 @@ mod tests { #[test] fn test_extract_result_types_ref_to_non_path() { // Test line 43: &(Tuple) - Reference to non-Path type - // This hits the else branch at line 42-43 + // Tests: else branch let ty: syn::Type = syn::parse_str("&(i32, String)").unwrap(); let result = extract_result_types(&ty); // The Reference's elem is a Tuple, not a Path, so line 39 condition fails @@ -487,7 +487,7 @@ mod tests { }; let ty = syn::Type::Path(type_path); - // This MUST hit line 48 because path.segments.is_empty() is true + // Tests: path.segments.is_empty() is true let result = extract_result_types(&ty); assert!( result.is_none(), @@ -518,7 +518,7 @@ mod tests { elem: Box::new(inner_ty), }); - // This goes through line 38-41 (reference to path), then hits line 48 + // Tests: reference to path then empty segments let result = extract_result_types(&ty); assert!( result.is_none(), @@ -535,7 +535,7 @@ mod tests { // Note: This doesn't actually work because is_keyword_type_by_type_path // checks for Result type, but ref to Result is different // The important thing is the code doesn't panic - // This exercises lines 38-41 even if result is None + // Tests: exercises reference path even if result is None } #[test] @@ -606,7 +606,7 @@ mod tests { // Should have 200 and 400 responses assert!(responses.contains_key("200")); let ok_response = responses.get("200").unwrap(); - // Headers should be None (line 95) + // Headers should be None assert!(ok_response.headers.is_none()); } } diff --git a/crates/vespera_macro/src/parser/schema/enum_schema.rs b/crates/vespera_macro/src/parser/schema/enum_schema.rs index dc8ad47..da9b994 100644 --- a/crates/vespera_macro/src/parser/schema/enum_schema.rs +++ b/crates/vespera_macro/src/parser/schema/enum_schema.rs @@ -1582,7 +1582,7 @@ mod tests { }); } - // Edge case: Empty struct variant (lines 275, 280 - empty properties/required) + // Edge case: Empty struct variant (empty properties/required) #[test] fn test_externally_tagged_empty_struct_variant() { let enum_item: syn::ItemEnum = syn::parse_str( @@ -1621,7 +1621,7 @@ mod tests { }); } - // Edge case: Internally tagged enum with tuple variant (line 468 - continue/skip) + // Edge case: Internally tagged enum with tuple variant #[test] fn test_internally_tagged_skips_tuple_variant() { let enum_item: syn::ItemEnum = syn::parse_str( @@ -1654,7 +1654,7 @@ mod tests { }); } - // Edge case: Untagged enum with tuple variant referencing a known schema (line 338) + // Edge case: Untagged enum with tuple variant referencing a known schema #[test] fn test_untagged_tuple_variant_with_known_schema_ref() { let enum_item: syn::ItemEnum = syn::parse_str( @@ -1704,7 +1704,7 @@ mod tests { } } - // Edge case: Untagged enum with multi-field tuple variant (lines 592, 600-611) + // Edge case: Untagged enum with multi-field tuple variant #[test] fn test_untagged_multi_field_tuple_variant() { let enum_item: syn::ItemEnum = syn::parse_str( diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index c03275c..c977e31 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -1492,7 +1492,7 @@ mod tests { assert!(skip_if_result); } - /// Test extract_rename_all fallback parsing (lines 44-47) + /// Test extract_rename_all fallback parsing #[test] fn test_extract_rename_all_fallback_manual_parsing() { let tokens = quote!(rename_all = "kebab-case"); @@ -1638,7 +1638,7 @@ mod tests { } /// Test extract_field_rename - ensure rename_all is not matched as rename - /// This tests the word boundary logic at lines 168-181 + /// Test the word boundary logic #[test] fn test_extract_field_rename_fallback_avoids_rename_all() { let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap(); @@ -1718,8 +1718,7 @@ mod tests { /// Test extract_field_rename with form_data but no field_name key #[test] fn test_extract_field_rename_form_data_no_field_name() { - let struct_src = - r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; + let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); if let syn::Fields::Named(fields) = &item.fields { let field = fields.named.first().unwrap(); @@ -1731,10 +1730,9 @@ mod tests { /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] #[test] fn test_extract_rename_all_try_from_multipart_fallback() { - let item: syn::ItemStruct = syn::parse_str( - r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#, - ) - .unwrap(); + let item: syn::ItemStruct = + syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#) + .unwrap(); let result = extract_rename_all(&item.attrs); assert_eq!(result.as_deref(), Some("camelCase")); } @@ -1742,10 +1740,7 @@ mod tests { /// Test serde rename_all takes priority over try_from_multipart rename_all #[test] fn test_extract_rename_all_serde_over_try_from_multipart() { - let item: syn::ItemStruct = syn::parse_str( - r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#, - ) - .unwrap(); + let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap(); let result = extract_rename_all(&item.attrs); assert_eq!(result.as_deref(), Some("snake_case")); } @@ -1753,10 +1748,8 @@ mod tests { /// Test extract_rename_all with try_from_multipart but no rename_all key #[test] fn test_extract_rename_all_try_from_multipart_no_rename_all() { - let item: syn::ItemStruct = syn::parse_str( - r#"#[try_from_multipart(strict)] struct Foo;"#, - ) - .unwrap(); + let item: syn::ItemStruct = + syn::parse_str(r#"#[try_from_multipart(strict)] struct Foo;"#).unwrap(); let result = extract_rename_all(&item.attrs); assert_eq!(result, None); } @@ -2047,7 +2040,7 @@ mod tests { } } - /// Test extract_tag with non-list serde attribute (line 524) + /// Test extract_tag with non-list serde attribute /// When require_list() fails, extract_tag should continue to next attribute #[test] fn test_extract_tag_non_list_attr_continues() { @@ -2064,7 +2057,7 @@ mod tests { assert_eq!(result.as_deref(), Some("type")); } - /// Test extract_tag with only non-list serde attribute returns None (line 524) + /// Test extract_tag with only non-list serde attribute returns None #[test] fn test_extract_tag_only_non_list_attr_returns_none() { let path_attr = create_path_only_serde_attr(); @@ -2072,7 +2065,7 @@ mod tests { assert_eq!(result, None); } - /// Test extract_content with non-list serde attribute (line 574) + /// Test extract_content with non-list serde attribute /// When require_list() fails, extract_content should continue to next attribute #[test] fn test_extract_content_non_list_attr_continues() { @@ -2089,7 +2082,7 @@ mod tests { assert_eq!(result.as_deref(), Some("data")); } - /// Test extract_content with only non-list serde attribute returns None (line 574) + /// Test extract_content with only non-list serde attribute returns None #[test] fn test_extract_content_only_non_list_attr_returns_none() { let path_attr = create_path_only_serde_attr(); diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index de31ba9..ab84e0a 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -232,15 +232,15 @@ pub(crate) fn parse_type_to_schema_ref_with_schemas( format: Some("duration".to_string()), ..Schema::string() })), - // File upload types (axum_typed_multipart / tempfile) - // FieldData → string with binary format - "FieldData" | "NamedTempFile" => SchemaRef::Inline(Box::new(Schema { - format: Some("binary".to_string()), - ..Schema::string() - })), - // Standard library types that should not be referenced - // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { + // File upload types (axum_typed_multipart / tempfile) + // FieldData → string with binary format + "FieldData" | "NamedTempFile" => SchemaRef::Inline(Box::new(Schema { + format: Some("binary".to_string()), + ..Schema::string() + })), + // Standard library types that should not be referenced + // Note: HashMap and BTreeMap are handled above in generic types + "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { // These are not schema types, return object schema SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) } @@ -755,7 +755,7 @@ mod tests { } } - // Tests for date/time types from chrono crate (lines 205-215) + // Tests for date/time types from chrono crate #[rstest] #[case("DateTime", "date-time")] #[case("NaiveDateTime", "date-time")] @@ -790,7 +790,7 @@ mod tests { } } - // Tests for date/time types from time crate (lines 218-228) + // Tests for date/time types from time crate #[rstest] #[case("OffsetDateTime", "date-time")] #[case("PrimitiveDateTime", "date-time")] @@ -822,7 +822,7 @@ mod tests { } } - // Test for Duration type (line 231-233) + // Test for Duration type #[test] fn test_parse_type_to_schema_ref_duration() { let ty: Type = syn::parse_str("Duration").unwrap(); diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_form_body.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_form_body.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_form_body.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_raw_multipart_body.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_raw_multipart_body.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_raw_multipart_body.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_multipart_body.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_multipart_body.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_multipart_body.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_typed_multipart.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_typed_multipart.snap new file mode 100644 index 0000000..bcf2d77 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_typed_multipart.snap @@ -0,0 +1,65 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "multipart/form-data": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 74d498d..d29bd16 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -897,7 +897,7 @@ mod tests { assert!(!output.contains("email : r . email")); } - // Coverage tests for lines 121-123, 156: FK field lookup and required relation handling + // Tests for FK field lookup and required relation handling #[test] fn test_is_circular_relation_required_belongs_to_with_from_attr_required_fk() { @@ -954,7 +954,7 @@ mod tests { assert!(!result); } - // Coverage test for line 156: generate_default_for_relation_field with required FK + // Tests for generate_default_for_relation_field with required FK #[test] fn test_generate_default_for_relation_field_belongs_to_with_from_attr_required() { diff --git a/crates/vespera_macro/src/schema_macro/file_lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup.rs index 81a38d4..8412db5 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup.rs @@ -663,7 +663,7 @@ pub struct Target { pub id: i32 } #[test] #[serial] fn test_find_struct_by_name_unreadable_file() { - // Coverage for line 122: Err(_) => continue + // Tests for error continuation // Create broken symlink that exists but can't be read let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path(); @@ -1065,7 +1065,7 @@ pub const NOT_STRUCT: i32 = 1; #[serial] fn test_find_struct_disambiguation_fallback_contains() { // Tests: No exact match, but fallback "contains" finds exactly one match - // This covers lines 169-174 (the fallback contains path) + // Tests for fallback contains path let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path(); @@ -1102,14 +1102,14 @@ pub const NOT_STRUCT: i32 = 1; } // ============================================================ - // Coverage tests for find_fk_column_from_target_entity (lines 287-333) + // Tests for find_fk_column_from_target_entity // ============================================================ #[test] #[serial] fn test_find_fk_column_from_target_entity_success() { // Tests: Full success path - find FK column from target entity - // Covers lines 287, 291-292, 296, 298, 305, 307-309, 312, 315-317, 320-323, 325 + // Full success path let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); @@ -1151,7 +1151,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_mod_rs() { - // Tests: Find FK column from mod.rs file (line 305 second path) + // Tests: Find FK column from mod.rs file let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models").join("notification"); @@ -1191,7 +1191,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_empty_module_segments() { - // Tests lines 300-301: Empty module segments return None + // Tests: Empty module segments return None let temp_dir = TempDir::new().unwrap(); let original = std::env::var("CARGO_MANIFEST_DIR").ok(); @@ -1214,7 +1214,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_file_not_found() { - // Tests lines 307-309: File doesn't exist -> continue, then return None (line 333) + // Tests: File does not exist -> continue, then return None let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); std::fs::create_dir_all(&src_dir).unwrap(); @@ -1240,7 +1240,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_unparseable_file() { - // Tests line 312: File can't be parsed -> returns None + // Tests: File cannot be parsed -> returns None let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); @@ -1269,7 +1269,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_no_model_struct() { - // Tests lines 315-317: File exists but has no Model struct + // Tests: File exists but has no Model struct let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); @@ -1307,7 +1307,7 @@ pub enum Status { Active, Inactive } #[test] #[serial] fn test_find_fk_column_from_target_entity_no_matching_relation_enum() { - // Tests lines 320-323: Model exists but no field matches the via_rel + // Tests: Model exists but no field matches the via_rel let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); @@ -1348,7 +1348,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_tuple_struct() { - // Tests line 320: Model is a tuple struct (not named fields) -> skip + // Tests: Model is a tuple struct (not named fields) -> skip let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); @@ -1378,7 +1378,7 @@ pub struct Model { #[test] #[serial] fn test_find_fk_column_from_target_entity_field_no_from_attr() { - // Tests line 325: Field matches relation_enum but has no `from` attribute + // Tests: Field matches relation_enum but has no `from` attribute let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); diff --git a/crates/vespera_macro/src/schema_macro/from_model.rs b/crates/vespera_macro/src/schema_macro/from_model.rs index 42053b8..2de379f 100644 --- a/crates/vespera_macro/src/schema_macro/from_model.rs +++ b/crates/vespera_macro/src/schema_macro/from_model.rs @@ -975,7 +975,7 @@ mod tests { #[test] #[serial] fn test_generate_from_model_needs_parent_stub_with_required_circular() { - // Coverage for lines 96, 98, 106, 108-109, 111-114, 117, 120, 124, 307 + // Tests for from_model generation // Tests: HasMany relation where target model has REQUIRED circular back-ref // This triggers needs_parent_stub = true and generates parent stub fields use tempfile::TempDir; @@ -1071,7 +1071,7 @@ pub struct Model { let output = tokens.to_string(); assert!(output.contains("impl UserSchema")); assert!(output.contains("from_model")); - // Should have parent stub with __parent_stub__ (line 307) + // Should have parent stub with __parent_stub__ assert!( output.contains("__parent_stub__"), "Should have parent stub: {}", @@ -1082,7 +1082,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_circular_has_one_optional() { - // Coverage for lines 200-202 + // Tests for field name resolution // Tests: HasOne with circular reference, optional use tempfile::TempDir; @@ -1168,7 +1168,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_circular_has_one_required() { - // Coverage for line 206 + // Tests for relation conversion failure // Tests: HasOne with circular reference, required use tempfile::TempDir; @@ -1258,7 +1258,7 @@ pub struct Model { #[test] fn test_generate_from_model_unknown_relation_with_inline_type() { - // Coverage for line 192 + // Tests for unknown relation type handling // Tests: Unknown relation type WITH inline_type_info -> Default::default() let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); let source_type: Type = syn::parse_str("Model").unwrap(); @@ -1315,7 +1315,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_non_circular_has_one_with_fk_optional() { - // Coverage for lines 221-222 + // Tests for field rename handling // Tests: HasOne with FK relations in target, no circular, optional use tempfile::TempDir; @@ -1402,7 +1402,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_non_circular_has_one_with_fk_required() { - // Coverage for line 229 + // Tests for parent stub generation // Tests: HasOne with FK relations in target, no circular, required use tempfile::TempDir; @@ -1499,7 +1499,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_has_many_with_circular() { - // Coverage for lines 261-262 + // Tests for quote generation // Tests: HasMany with circular reference use tempfile::TempDir; @@ -1595,7 +1595,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_has_many_with_fk_no_circular() { - // Coverage for lines 272-276, 278 + // Tests for multi-variant case handling // Tests: HasMany with FK relations in target, no circular use tempfile::TempDir; @@ -1692,7 +1692,6 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_inline_type_required() { - // Coverage for lines in inline type with required relation // Tests: inline_type_info with required BelongsTo use tempfile::TempDir; @@ -1786,7 +1785,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_parent_stub_all_relation_types() { - // Coverage for lines 114, 117, 120 + // Tests for relation type variants // Tests: Parent stub generation with: use tempfile::TempDir; @@ -1842,28 +1841,28 @@ pub struct Model { false, false, ), - // HasMany (line 113) - this one triggers needs_parent_stub + // HasMany - this one triggers needs_parent_stub ( syn::Ident::new("memos", proc_macro2::Span::call_site()), syn::Ident::new("memos", proc_macro2::Span::call_site()), false, true, ), - // Optional single relation (line 114) + // Optional single relation ( syn::Ident::new("profile", proc_macro2::Span::call_site()), syn::Ident::new("profile", proc_macro2::Span::call_site()), false, true, ), - // Required single relation (line 117) + // Required single relation ( syn::Ident::new("settings", proc_macro2::Span::call_site()), syn::Ident::new("settings", proc_macro2::Span::call_site()), false, true, ), - // Relation field NOT in relation_fields (line 120) + // Relation field NOT in relation_fields ( syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), @@ -1872,7 +1871,7 @@ pub struct Model { ), ]; - // Relation fields - note: orphan_rel is NOT included here (hits line 120) + // Relation fields - note: orphan_rel is NOT included here let relation_fields = vec![ // HasMany without inline_type_info (triggers needs_parent_stub) create_test_relation_info( @@ -1881,21 +1880,21 @@ pub struct Model { quote! { crate::models::memo::Schema }, false, ), - // Optional HasOne (hits line 114) + // Optional HasOne create_test_relation_info( "profile", "HasOne", quote! { crate::models::profile::Schema }, true, // optional ), - // Required BelongsTo (hits line 117) + // Required BelongsTo create_test_relation_info( "settings", "BelongsTo", quote! { crate::models::settings::Schema }, false, // required ), - // Note: orphan_rel is NOT in relation_fields (hits line 120) + // Note: orphan_rel is NOT in relation_fields ]; let source_module_path = vec![ @@ -1924,7 +1923,7 @@ pub struct Model { let output = tokens.to_string(); assert!(output.contains("impl UserSchema")); - // Should have parent stub (line 307) + // Should have parent stub assert!( output.contains("__parent_stub__"), "Should have parent stub: {}", @@ -1953,7 +1952,7 @@ pub struct Model { } // ============================================================ - // Coverage tests for relation_enum + fk_column branches (lines 70-106) + // Tests for relation_enum + fk_column branches // ============================================================ fn create_test_relation_info_full( @@ -1979,7 +1978,7 @@ pub struct Model { #[test] fn test_generate_from_model_has_one_with_relation_enum_optional_with_fk() { - // Coverage for lines 70, 72, 74-76 + // Tests for field name comparison // Tests: HasOne with relation_enum + optional + fk_column present let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); let source_type: Type = syn::parse_str("Model").unwrap(); @@ -2046,7 +2045,7 @@ pub struct Model { #[test] fn test_generate_from_model_has_one_with_relation_enum_optional_no_fk() { - // Coverage for line 84 + // Tests for None branch // Tests: HasOne with relation_enum + optional + NO fk_column (fallback) let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); let source_type: Type = syn::parse_str("Model").unwrap(); @@ -2103,7 +2102,7 @@ pub struct Model { #[test] fn test_generate_from_model_belongs_to_with_relation_enum_required_with_fk() { - // Coverage for lines 93-95 + // Tests for required relation field // Tests: BelongsTo with relation_enum + required + fk_column present let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); let source_type: Type = syn::parse_str("Model").unwrap(); @@ -2160,7 +2159,7 @@ pub struct Model { #[test] fn test_generate_from_model_belongs_to_with_relation_enum_required_no_fk() { - // Coverage for line 100 + // Tests for skip condition // Tests: BelongsTo with relation_enum + required + NO fk_column (fallback) let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); let source_type: Type = syn::parse_str("Model").unwrap(); @@ -2216,13 +2215,13 @@ pub struct Model { } // ============================================================ - // Coverage tests for HasMany with via_rel/relation_enum (lines 118-182) + // Tests for HasMany with via_rel/relation_enum // ============================================================ #[test] #[serial] fn test_generate_from_model_has_many_with_via_rel_fk_found() { - // Coverage for lines 120-121, 123-124, 128-130, 132 + // Tests for HasMany with via_rel + FK column found // Tests: HasMany with via_rel + FK column found in target entity use tempfile::TempDir; @@ -2325,7 +2324,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_has_many_with_via_rel_fk_not_found() { - // Coverage for line 144 + // Tests for HasMany via_rel not found // Tests: HasMany with via_rel but FK column NOT found in target entity use tempfile::TempDir; @@ -2410,7 +2409,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_has_many_with_relation_enum_fk_found() { - // Coverage for lines 151-154, 156-158, 160 + // Tests for via_rel field matching // Tests: HasMany with relation_enum (no via_rel) + FK column found use tempfile::TempDir; @@ -2508,7 +2507,7 @@ pub struct Model { #[test] #[serial] fn test_generate_from_model_has_many_with_relation_enum_fk_not_found() { - // Coverage for line 172 + // Tests for HasMany via_rel generation // Tests: HasMany with relation_enum (no via_rel) + FK column NOT found use tempfile::TempDir; diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index bd84d44..8015228 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -717,7 +717,7 @@ mod tests { #[test] fn test_generate_inline_relation_type_from_def_skips_relation_types() { - // Test that relation types (HasOne, HasMany, BelongsTo) are skipped (line 87) + // Test that relation types (HasOne, HasMany, BelongsTo) are skipped let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); let rel_info = RelationFieldInfo { field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), @@ -771,7 +771,7 @@ mod tests { #[test] fn test_generate_inline_relation_type_from_def_skips_serde_skip() { - // Test that fields with serde(skip) are skipped (line 92) + // Test that fields with serde(skip) are skipped let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); let rel_info = RelationFieldInfo { field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), @@ -824,7 +824,7 @@ mod tests { #[test] fn test_generate_inline_relation_type_no_relations_from_def_with_schema_name_override() { - // Test schema_name_override Some branch (line 133) + // Test schema_name_override Some branch let parent_type_name = syn::Ident::new("Schema", proc_macro2::Span::call_site()); let rel_info = RelationFieldInfo { field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), @@ -857,7 +857,7 @@ mod tests { assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); } - // Tests for public functions with file lookup (lines 43, 45, 114, 116-118, 120) + // Tests for public functions with file lookup // These require setting up a temp directory with model files #[test] @@ -886,7 +886,7 @@ pub struct Model { // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // Test generate_inline_relation_type (lines 43, 45) + // Test generate_inline_relation_type let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); let rel_info = RelationFieldInfo { field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), @@ -960,7 +960,7 @@ pub struct Model { // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // Test generate_inline_relation_type_no_relations (lines 114, 116-118, 120) + // Test generate_inline_relation_type_no_relations let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); let rel_info = RelationFieldInfo { field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), diff --git a/crates/vespera_macro/src/schema_macro/input.rs b/crates/vespera_macro/src/schema_macro/input.rs index 30caea2..e3dcd90 100644 --- a/crates/vespera_macro/src/schema_macro/input.rs +++ b/crates/vespera_macro/src/schema_macro/input.rs @@ -3,10 +3,9 @@ //! Defines input structures for `schema!` and `schema_type!` macros. use syn::{ - bracketed, parenthesized, + Ident, LitStr, Token, Type, bracketed, parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, - Ident, LitStr, Token, Type, }; /// Input for the schema! macro diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 4a162b3..62a670e 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -474,7 +474,11 @@ pub fn generate_schema_type_code( // Build derive list // In multipart mode, force clone = false (FieldData doesn't implement Clone) - let derive_clone = if input.multipart { false } else { input.derive_clone }; + let derive_clone = if input.multipart { + false + } else { + input.derive_clone + }; let clone_derive = if derive_clone { quote! { Clone, } } else { diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index 4e39e02..54e8c93 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -616,6 +616,64 @@ mod tests { assert!(tokens.to_string().is_empty() || tokens.to_string().trim().is_empty()); } + // ========================================================================= + // Tests for FieldData/NamedTempFile type conversion + // ========================================================================= + + #[test] + fn test_convert_seaorm_type_field_data_with_generic() { + // FieldData → vespera::axum_typed_multipart::FieldData + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + let output = tokens.to_string(); + assert!( + output.contains("vespera :: axum_typed_multipart :: FieldData"), + "Should resolve FieldData via vespera re-export: {output}" + ); + assert!( + output.contains("vespera :: tempfile :: NamedTempFile"), + "Should resolve inner NamedTempFile via vespera re-export: {output}" + ); + } + + #[test] + fn test_convert_seaorm_type_field_data_without_generic() { + // FieldData (no generics) → vespera::axum_typed_multipart::FieldData + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + let output = tokens.to_string(); + assert!( + output.contains("vespera :: axum_typed_multipart :: FieldData"), + "Should resolve bare FieldData: {output}" + ); + // Should NOT contain nested generic + assert!( + !output.contains("NamedTempFile"), + "Bare FieldData should not have NamedTempFile: {output}" + ); + } + + #[test] + fn test_convert_seaorm_type_field_data_with_non_type_generic() { + // FieldData with a non-Type generic arg (e.g., lifetime) should use fallback quote + let ty: syn::Type = syn::parse_str("FieldData<'a>").unwrap(); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + let output = tokens.to_string(); + assert!( + output.contains("vespera :: axum_typed_multipart :: FieldData"), + "Should still resolve FieldData: {output}" + ); + } + + #[test] + fn test_convert_seaorm_type_named_temp_file() { + // NamedTempFile → vespera::tempfile::NamedTempFile + let ty: syn::Type = syn::parse_str("NamedTempFile").unwrap(); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + let output = tokens.to_string(); + assert_eq!(output.trim(), "vespera :: tempfile :: NamedTempFile"); + } + // ========================================================================= // Tests for convert_relation_type_to_schema_with_info // ========================================================================= @@ -928,12 +986,12 @@ mod tests { } // ========================================================================= - // Tests for extract_via_rel (coverage for lines 172-186) + // Tests for extract_via_rel // ========================================================================= #[test] fn test_extract_via_rel_with_value() { - // Tests line 178-179: via_rel = "..." found + // Tests: via_rel = "..." found let attrs: Vec = vec![syn::parse_quote!( #[sea_orm(has_many, via_rel = "TargetUser")] )]; @@ -943,7 +1001,7 @@ mod tests { #[test] fn test_extract_via_rel_with_relation_enum() { - // Tests line 178-179: via_rel alongside other attributes + // Tests: via_rel alongside other attributes let attrs: Vec = vec![syn::parse_quote!( #[sea_orm(has_many, relation_enum = "TargetUserNotifications", via_rel = "TargetUser")] )]; @@ -963,7 +1021,7 @@ mod tests { #[test] fn test_extract_via_rel_non_sea_orm_attr() { - // Tests line 172-173: Non-sea_orm attribute returns None + // Tests: Non-sea_orm attribute returns None let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; let result = extract_via_rel(&attrs); assert_eq!(result, None); @@ -978,7 +1036,7 @@ mod tests { #[test] fn test_extract_via_rel_with_other_key_value_pairs() { - // Tests line 180-182: Other key=value pairs are consumed without error + // Tests: Other key=value pairs are consumed without error let attrs: Vec = vec![syn::parse_quote!( #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", via_rel = "Author")] )]; diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 2206e21..624035e 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -348,7 +348,7 @@ fn test_generate_schema_type_code_preserves_struct_doc() { assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); } -// Coverage tests for lines 187-206: Serde attribute filtering from source struct +// Tests for serde attribute filtering from source struct #[test] fn test_generate_schema_type_code_inherits_source_rename_all() { @@ -391,7 +391,7 @@ fn test_generate_schema_type_code_override_rename_all() { assert!(output.contains("camelCase")); } -// Coverage tests for lines 313-358: Field rename processing +// Tests for field rename processing #[test] fn test_generate_schema_type_code_with_rename() { @@ -437,7 +437,7 @@ fn test_generate_schema_type_code_rename_preserves_serde_rename() { assert!(output.contains("userName") || output.contains("rename")); } -// Coverage tests for lines 389-400: Schema derive and name attribute generation +// Tests for schema derive and name attribute generation #[test] fn test_generate_schema_type_code_with_ignore_schema() { @@ -498,7 +498,7 @@ fn test_generate_schema_type_code_with_clone_false() { assert!(!output.contains("Clone ,")); } -// Coverage test for SeaORM model detection (lines 212-213) +// Test for SeaORM model detection #[test] fn test_generate_schema_type_code_seaorm_model_detection() { @@ -605,7 +605,7 @@ fn test_generate_schema_code_excludes_serde_skip_fields() { assert!(output.contains("name")); } -// Coverage tests for lines 81-83: Qualified path storage fallback +// Tests for qualified path storage fallback // Note: This tests the case where is_qualified_path returns true // and we find the struct in schema_storage rather than via file lookup @@ -631,7 +631,7 @@ fn test_generate_schema_type_code_qualified_path_storage_lookup() { assert!(output.contains("UserSchema")); } -// Coverage test for lines 85-91: Qualified path not found error +// Test for qualified path not found error #[test] fn test_generate_schema_type_code_qualified_path_not_found() { @@ -648,7 +648,7 @@ fn test_generate_schema_type_code_qualified_path_not_found() { assert!(err.contains("not found")); } -// Coverage tests for lines 252, 254-255: HasMany excluded by default +// Tests for HasMany excluded by default #[test] fn test_generate_schema_type_code_has_many_excluded_by_default() { @@ -676,7 +676,7 @@ fn test_generate_schema_type_code_has_many_excluded_by_default() { assert!(output.contains("name")); } -// Coverage test for line 302: Relation conversion failure skip +// Test for relation conversion failure skip #[test] fn test_generate_schema_type_code_relation_conversion_failure() { @@ -759,7 +759,7 @@ fn test_generate_schema_type_code_has_one_relation() { assert!(output.contains("profile")); } -// Coverage test for line 313: Relation fields push into relation_fields +// Test for relation fields push into relation_fields #[test] fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_model() { @@ -788,8 +788,8 @@ fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_mode // The From impl is only generated when there are no relation fields } -// Coverage test for line 438: from_model generation with relations -// Note: This line requires is_source_seaorm_model && has_relation_fields +// Test for from_model generation with relations +// Note: This requires is_source_seaorm_model && has_relation_fields // The from_model generation happens but needs file lookup for full path #[test] @@ -821,7 +821,6 @@ fn test_generate_schema_type_code_from_model_generation() { #[test] #[serial] fn test_generate_schema_type_code_qualified_path_file_lookup_success() { - // Coverage for lines 76, 78-79, 81 // Tests: qualified path found via file lookup, module_path used when source is empty use tempfile::TempDir; @@ -845,7 +844,7 @@ pub struct Model { // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // Use qualified path - file lookup should succeed (lines 75-81) + // Use qualified path - file lookup should succeed let tokens = quote!(UserSchema from crate::models::user::Model); let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); let storage: Vec = vec![]; // Empty storage - force file lookup @@ -874,7 +873,6 @@ pub struct Model { #[test] #[serial] fn test_generate_schema_type_code_simple_name_file_lookup_fallback() { - // Coverage for lines 100, 103-104 // Tests: simple name (not in storage) found via file lookup with schema_name hint use tempfile::TempDir; @@ -927,13 +925,12 @@ pub struct Model { } // ============================================================ -// Coverage tests for HasMany explicit pick with inline type (lines 258-270) +// Tests for HasMany explicit pick with inline type // ============================================================ #[test] #[serial] fn test_generate_schema_type_code_has_many_explicit_pick_inline_type() { - // Coverage for lines 258-260, 262-263, 265, 267-268 // Tests: HasMany is explicitly picked, inline type is generated use tempfile::TempDir; @@ -998,7 +995,6 @@ pub struct Model { #[test] #[serial] fn test_generate_schema_type_code_has_many_explicit_pick_file_not_found() { - // Coverage for line 270 // Tests: HasMany is explicitly picked but target file not found - should skip field use tempfile::TempDir; @@ -1051,13 +1047,12 @@ pub struct Model { } // ============================================================ -// Coverage tests for BelongsTo/HasOne circular reference inline types (lines 277-294) +// Tests for BelongsTo/HasOne circular reference inline types // ============================================================ #[test] #[serial] fn test_generate_schema_type_code_belongs_to_circular_inline_optional() { - // Coverage for lines 277-278, 281-282, 285, 288-289, 294 // Tests: BelongsTo with circular reference, optional field (is_optional = true) use tempfile::TempDir; @@ -1124,7 +1119,6 @@ pub struct Model { #[test] #[serial] fn test_generate_schema_type_code_has_one_circular_inline_required() { - // Coverage for lines 277-278, 281-282, 285, 291, 294 // Tests: HasOne with circular reference, required field (is_optional = false) use tempfile::TempDir; @@ -1193,7 +1187,6 @@ pub struct Model { #[test] #[serial] fn test_generate_schema_type_code_belongs_to_circular_inline_required_file() { - // Coverage for line 291 specifically // Tests: BelongsTo with circular reference AND required FK (is_optional = false) // This requires file-based lookup with: // 1. #[sea_orm(from = "required_fk")] where required_fk is NOT Option @@ -1271,7 +1264,6 @@ pub struct Model { output ); // BelongsTo with required FK (user_id: i32) should generate Box<...> not Option> - // This hits line 291: quote! { Box<#inline_type_name> } assert!( output.contains("pub user : Box <"), "BelongsTo with required FK should generate Box<>, not Option>. Output: {}", @@ -1374,10 +1366,136 @@ fn test_extract_belongs_to_from_field_with_equals_value() { ); } +// ============================================================ +// Tests for multipart mode +// ============================================================ + +#[test] +fn test_generate_schema_type_code_multipart_basic() { + // Tests: multipart mode generates TryFromMultipart derive, suppresses From impl + let storage = vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub description: Option }", + )]; + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive TryFromMultipart + assert!(output.contains("TryFromMultipart")); + // Should NOT have From impl (multipart suppresses it) + assert!(!output.contains("impl From")); + // Should have the struct fields + assert!(output.contains("name")); + assert!(output.contains("description")); +} + +#[test] +fn test_generate_schema_type_code_multipart_with_rename() { + // Tests: multipart mode with field rename + let storage = vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub file_path: String }", + )]; + + let tokens = quote!(RenamedUpload from UploadRequest, multipart, rename = [("file_path", "document_path")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive TryFromMultipart + assert!(output.contains("TryFromMultipart")); + // Should have renamed field + assert!(output.contains("document_path")); + // Original name should NOT appear as field + assert!(!output.contains("file_path")); +} + +#[test] +fn test_generate_schema_type_code_multipart_with_form_data_attrs() { + // Tests: multipart mode preserves #[form_data] attributes from source + let storage = vec![create_test_struct_metadata( + "UploadRequest", + r#"pub struct UploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: String + }"#, + )]; + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should preserve form_data attributes + assert!(output.contains("form_data")); + assert!(output.contains("limit")); +} + +#[test] +fn test_generate_schema_type_code_multipart_skips_relations() { + // Tests: multipart mode skips relation fields + let storage = vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo + }"#, + )]; + + let tokens = quote!(MemoUpload from Model, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Relation field should be skipped in multipart mode + assert!(!output.contains("user")); + // Regular fields should be present + assert!(output.contains("id")); + assert!(output.contains("title")); + // Should derive TryFromMultipart + assert!(output.contains("TryFromMultipart")); +} + +#[test] +fn test_generate_schema_type_code_multipart_partial() { + // Coverage for multipart + partial combination + let storage = vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub tags: String }", + )]; + + let tokens = quote!(PatchUpload from UploadRequest, multipart, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive TryFromMultipart + assert!(output.contains("TryFromMultipart")); + // Fields should be wrapped in Option (partial) + assert!(output.contains("Option")); + // Should NOT have From impl + assert!(!output.contains("impl From")); +} + #[test] #[serial] fn test_generate_schema_type_code_qualified_path_with_nonempty_module_path() { - // Coverage for line 78 (the else branch where source_module_path is NOT empty) // Tests: qualified path with explicit module segments that are not empty use tempfile::TempDir;