diff --git a/.changepacks/changepack_log_EpxhrM524BIpZTRmhX9z1.json b/.changepacks/changepack_log_EpxhrM524BIpZTRmhX9z1.json new file mode 100644 index 0000000..bebb53b --- /dev/null +++ b/.changepacks/changepack_log_EpxhrM524BIpZTRmhX9z1.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Support nested generic","date":"2026-01-26T07:06:46.307922700Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 101519c..f38855c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,7 +1702,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.22" +version = "0.1.23" dependencies = [ "axum", "axum-extra", @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.22" +version = "0.1.23" dependencies = [ "rstest", "serde", @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.22" +version = "0.1.23" dependencies = [ "anyhow", "insta", diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index a1688ab..ee86909 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -699,35 +699,118 @@ pub fn parse_struct_to_schema( } fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type { - // Check if this is a generic parameter - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.last() - { - let ident_str = segment.ident.to_string(); - if generic_params.contains(&ident_str) && segment.arguments.is_none() { - // Find the index and substitute - if let Some(index) = generic_params.iter().position(|p| p == &ident_str) - && let Some(concrete_ty) = concrete_types.get(index) - { - return (*concrete_ty).clone(); + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.is_empty() { + return ty.clone(); } - } - } - // For complex types, use quote! to regenerate with substitutions - let tokens = quote::quote! { #ty }; - let mut new_tokens = tokens.to_string(); + // Check if this is a direct generic parameter (e.g., just "T" with no arguments) + if path.segments.len() == 1 { + let segment = &path.segments[0]; + let ident_str = segment.ident.to_string(); + + if let syn::PathArguments::None = &segment.arguments { + // Direct generic parameter substitution + if let Some(index) = generic_params.iter().position(|p| p == &ident_str) + && let Some(concrete_ty) = concrete_types.get(index) { + return (*concrete_ty).clone(); + } + } + } - // Replace generic parameter names with concrete types - for (param, concrete_ty) in generic_params.iter().zip(concrete_types.iter()) { - // Replace standalone generic parameter (not part of another identifier) - let pattern = format!(r"\b{}\b", param); - let replacement = quote::quote! { #concrete_ty }.to_string(); - new_tokens = new_tokens.replace(&pattern, &replacement); - } + // For types with generic arguments (e.g., Vec, Option, HashMap), + // recursively substitute the type arguments + let mut new_segments = syn::punctuated::Punctuated::new(); + for segment in &path.segments { + let new_arguments = match &segment.arguments { + syn::PathArguments::AngleBracketed(args) => { + let mut new_args = syn::punctuated::Punctuated::new(); + for arg in &args.args { + let new_arg = match arg { + syn::GenericArgument::Type(inner_ty) => syn::GenericArgument::Type( + substitute_type(inner_ty, generic_params, concrete_types), + ), + other => other.clone(), + }; + new_args.push(new_arg); + } + syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { + colon2_token: args.colon2_token, + lt_token: args.lt_token, + args: new_args, + gt_token: args.gt_token, + }) + } + other => other.clone(), + }; + + new_segments.push(syn::PathSegment { + ident: segment.ident.clone(), + arguments: new_arguments, + }); + } - // Parse the substituted type - syn::parse_str::(&new_tokens).unwrap_or_else(|_| ty.clone()) + Type::Path(syn::TypePath { + qself: type_path.qself.clone(), + path: syn::Path { + leading_colon: path.leading_colon, + segments: new_segments, + }, + }) + } + Type::Reference(type_ref) => { + // Handle &T, &mut T + Type::Reference(syn::TypeReference { + and_token: type_ref.and_token, + lifetime: type_ref.lifetime.clone(), + mutability: type_ref.mutability, + elem: Box::new(substitute_type( + &type_ref.elem, + generic_params, + concrete_types, + )), + }) + } + Type::Slice(type_slice) => { + // Handle [T] + Type::Slice(syn::TypeSlice { + bracket_token: type_slice.bracket_token, + elem: Box::new(substitute_type( + &type_slice.elem, + generic_params, + concrete_types, + )), + }) + } + Type::Array(type_array) => { + // Handle [T; N] + Type::Array(syn::TypeArray { + bracket_token: type_array.bracket_token, + elem: Box::new(substitute_type( + &type_array.elem, + generic_params, + concrete_types, + )), + semi_token: type_array.semi_token, + len: type_array.len.clone(), + }) + } + Type::Tuple(type_tuple) => { + // Handle (T1, T2, ...) + let new_elems = type_tuple + .elems + .iter() + .map(|elem| substitute_type(elem, generic_params, concrete_types)) + .collect(); + Type::Tuple(syn::TypeTuple { + paren_token: type_tuple.paren_token, + elems: new_elems, + }) + } + _ => ty.clone(), + } } pub(super) fn is_primitive_type(ty: &Type) -> bool { @@ -1561,6 +1644,107 @@ mod tests { assert_eq!(substituted, ty); } + #[rstest] + // Direct generic param substitution + #[case("T", &["T"], &["String"], "String")] + // Vec substitution + #[case("Vec", &["T"], &["String"], "Vec < String >")] + // Option substitution + #[case("Option", &["T"], &["i32"], "Option < i32 >")] + // Nested: Vec> + #[case("Vec>", &["T"], &["String"], "Vec < Option < String > >")] + // Deeply nested: Option>> + #[case("Option>>", &["T"], &["bool"], "Option < Vec < Option < bool > > >")] + // Multiple generic params + #[case("HashMap", &["K", "V"], &["String", "i32"], "HashMap < String , i32 >")] + // Generic param not in list (unchanged) + #[case("Vec", &["T"], &["String"], "Vec < U >")] + // Non-generic type (unchanged) + #[case("String", &["T"], &["i32"], "String")] + // Reference type: &T + #[case("&T", &["T"], &["String"], "& String")] + // Mutable reference: &mut T + #[case("&mut T", &["T"], &["i32"], "& mut i32")] + // Slice type: [T] + #[case("[T]", &["T"], &["String"], "[String]")] + // Array type: [T; 5] + #[case("[T; 5]", &["T"], &["u8"], "[u8 ; 5]")] + // Tuple type: (T, U) + #[case("(T, U)", &["T", "U"], &["String", "i32"], "(String , i32)")] + // Complex nested tuple + #[case("(Vec, Option)", &["T", "U"], &["String", "bool"], "(Vec < String > , Option < bool >)")] + // Reference to Vec + #[case("&Vec", &["T"], &["String"], "& Vec < String >")] + // Multi-segment path (no substitution for crate::Type) + #[case("std::vec::Vec", &["T"], &["String"], "std :: vec :: Vec < String >")] + fn test_substitute_type_comprehensive( + #[case] input: &str, + #[case] params: &[&str], + #[case] concrete: &[&str], + #[case] expected: &str, + ) { + let ty: Type = syn::parse_str(input).unwrap(); + let generic_params: Vec = params.iter().map(|s| s.to_string()).collect(); + let concrete_types: Vec = concrete.iter().map(|s| syn::parse_str(s).unwrap()).collect(); + let concrete_refs: Vec<&Type> = concrete_types.iter().collect(); + + let result = substitute_type(&ty, &generic_params, &concrete_refs); + let result_str = quote::quote!(#result).to_string(); + + assert_eq!(result_str, expected, "Input: {}", input); + } + + #[test] + fn test_substitute_type_empty_path_segments() { + // Create a TypePath with empty segments + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let concrete: Type = syn::parse_str("String").unwrap(); + let result = substitute_type(&ty, &[String::from("T")], &[&concrete]); + // Should return the original type unchanged + assert_eq!(result, ty); + } + + #[test] + fn test_substitute_type_with_lifetime_generic_argument() { + // Test type with lifetime: Cow<'static, T> + // The lifetime argument should be preserved while T is substituted + let ty: Type = syn::parse_str("std::borrow::Cow<'static, T>").unwrap(); + let concrete: Type = syn::parse_str("String").unwrap(); + let result = substitute_type(&ty, &[String::from("T")], &[&concrete]); + let result_str = quote::quote!(#result).to_string(); + // Lifetime 'static should be preserved, T should be substituted + assert_eq!(result_str, "std :: borrow :: Cow < 'static , String >"); + } + + #[test] + fn test_substitute_type_parenthesized_args() { + // Fn(T) -> U style (parenthesized arguments) + // This tests the `other => other.clone()` branch for PathArguments + let ty: Type = syn::parse_str("fn(T) -> U").unwrap(); + let concrete_t: Type = syn::parse_str("String").unwrap(); + let concrete_u: Type = syn::parse_str("i32").unwrap(); + let result = substitute_type(&ty, &[String::from("T"), String::from("U")], &[&concrete_t, &concrete_u]); + // Type::BareFn doesn't go through the Path branch, falls to _ => ty.clone() + assert_eq!(result, ty); + } + + #[test] + fn test_substitute_type_path_without_angle_brackets() { + // Test path with parenthesized arguments: Fn(T) -> U as a trait + let ty: Type = syn::parse_str("dyn Fn(T) -> U").unwrap(); + let concrete_t: Type = syn::parse_str("String").unwrap(); + let concrete_u: Type = syn::parse_str("i32").unwrap(); + let result = substitute_type(&ty, &[String::from("T"), String::from("U")], &[&concrete_t, &concrete_u]); + // Type::TraitObject falls to _ => ty.clone() + assert_eq!(result, ty); + } + #[rstest] #[case("&i32")] #[case("std::string::String")] diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 00ba569..95c903b 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -471,6 +471,56 @@ } } }, + "/generic/generic5": { + "get": { + "operationId": "generic_endpoint5", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactResponse" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/health": { "get": { "operationId": "health", @@ -1257,6 +1307,49 @@ "nestedStructMapArray" ] }, + "ContactResponse": { + "type": "object", + "properties": { + "adminReply": { + "type": "string", + "nullable": true + }, + "category": { + "type": "string", + "nullable": true + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "repliedAt": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt" + ] + }, "CreateUserRequest": { "type": "object", "properties": { @@ -1591,6 +1684,32 @@ "age" ] }, + "PaginatedResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + }, "SignupRequest": { "type": "object", "properties": { diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs index 45857d0..3262370 100644 --- a/examples/axum-example/src/routes/generic.rs +++ b/examples/axum-example/src/routes/generic.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use vespera::axum::Json; +use vespera::{Schema, axum::Json}; use crate::TestStruct; @@ -57,3 +57,44 @@ pub async fn generic_endpoint4() -> Json> { name: "John Doe".to_string(), }) } +#[derive(Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ContactResponse { + pub id: i64, + pub user_id: i64, + pub category: Option, + pub title: String, + pub content: String, + pub admin_reply: Option, + pub replied_at: Option, + pub created_at: String, + pub updated_at: Option, +} +#[derive(Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct PaginatedResponse { + pub items: Vec, + pub page: i32, + pub size: i32, + pub total_page: i32, +} +#[vespera::route(get, path = "/generic5")] +pub async fn generic_endpoint5() +-> Result>, (vespera::axum::http::StatusCode, String)> { + Ok(Json(PaginatedResponse { + items: vec![ContactResponse { + id: 1, + user_id: 1, + category: Some("test".to_string()), + title: "test".to_string(), + content: "test".to_string(), + admin_reply: None, + replied_at: None, + created_at: "2021-01-01".to_string(), + updated_at: None, + }], + page: 1, + size: 10, + total_page: 1, + })) +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 6387b0f..1b5d6dc 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -475,6 +475,56 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/generic/generic5": { + "get": { + "operationId": "generic_endpoint5", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactResponse" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/health": { "get": { "operationId": "health", @@ -1261,6 +1311,49 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nestedStructMapArray" ] }, + "ContactResponse": { + "type": "object", + "properties": { + "adminReply": { + "type": "string", + "nullable": true + }, + "category": { + "type": "string", + "nullable": true + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "repliedAt": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt" + ] + }, "CreateUserRequest": { "type": "object", "properties": { @@ -1595,6 +1688,32 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "age" ] }, + "PaginatedResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + }, "SignupRequest": { "type": "object", "properties": { diff --git a/openapi.json b/openapi.json index 00ba569..95c903b 100644 --- a/openapi.json +++ b/openapi.json @@ -471,6 +471,56 @@ } } }, + "/generic/generic5": { + "get": { + "operationId": "generic_endpoint5", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactResponse" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/health": { "get": { "operationId": "health", @@ -1257,6 +1307,49 @@ "nestedStructMapArray" ] }, + "ContactResponse": { + "type": "object", + "properties": { + "adminReply": { + "type": "string", + "nullable": true + }, + "category": { + "type": "string", + "nullable": true + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "repliedAt": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt" + ] + }, "CreateUserRequest": { "type": "object", "properties": { @@ -1591,6 +1684,32 @@ "age" ] }, + "PaginatedResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + }, + "required": [ + "items", + "page", + "size", + "totalPage" + ] + }, "SignupRequest": { "type": "object", "properties": {