From 07e9cc478effaea7df8605ffdce0285bdd87ff61 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 14:43:17 +0900 Subject: [PATCH 1/6] Implement generic --- Cargo.lock | 6 +- crates/vespera_macro/src/lib.rs | 5 +- crates/vespera_macro/src/openapi_generator.rs | 13 +- crates/vespera_macro/src/parser.rs | 290 ++++++++++++++++-- examples/axum-example/openapi.json | 95 ++++++ examples/axum-example/src/routes/generic.rs | 33 ++ examples/axum-example/src/routes/mod.rs | 1 + 7 files changed, 408 insertions(+), 35 deletions(-) create mode 100644 examples/axum-example/src/routes/generic.rs diff --git a/Cargo.lock b/Cargo.lock index 4bf6a15..4130284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1501,7 +1501,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.6" +version = "0.1.7" dependencies = [ "axum", "vespera_core", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.6" +version = "0.1.7" dependencies = [ "rstest", "serde", @@ -1519,7 +1519,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "proc-macro2", diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 37adf1b..a263ae5 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -36,6 +36,7 @@ static SCHEMA_STORAGE: LazyLock>> = pub fn derive_schema(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); let name = &input.ident; + let generics = &input.generics; let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); schema_storage.push(StructMetadata { @@ -44,9 +45,11 @@ pub fn derive_schema(input: TokenStream) -> TokenStream { }); // Mark both struct and enum as having SchemaBuilder + // For generic types, include the generic parameters in the impl // The actual schema generation will be done at runtime + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let expanded = quote! { - impl vespera::schema::SchemaBuilder for #name {} + impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {} }; TokenStream::from(expanded) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 504e316..84fed24 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -20,11 +20,14 @@ pub fn generate_openapi_doc_with_metadata( let mut schemas: BTreeMap = BTreeMap::new(); let mut known_schema_names: std::collections::HashMap = std::collections::HashMap::new(); + let mut struct_definitions: std::collections::HashMap = + std::collections::HashMap::new(); - // First, register all schema names so they can be referenced during parsing + // First, register all schema names and store struct definitions for struct_meta in &metadata.structs { let schema_name = struct_meta.name.clone(); known_schema_names.insert(schema_name.clone(), schema_name); + struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone()); } // Then, parse all struct and enum schemas (now they can reference each other) @@ -32,14 +35,17 @@ pub fn generate_openapi_doc_with_metadata( let parsed = syn::parse_str::(&struct_meta.definition).unwrap(); let schema = match parsed { syn::Item::Struct(struct_item) => { - parse_struct_to_schema(&struct_item, &known_schema_names) + parse_struct_to_schema(&struct_item, &known_schema_names, &struct_definitions) + } + syn::Item::Enum(enum_item) => { + parse_enum_to_schema(&enum_item, &known_schema_names, &struct_definitions) } - syn::Item::Enum(enum_item) => parse_enum_to_schema(&enum_item, &known_schema_names), _ => { // Fallback to struct parsing for backward compatibility parse_struct_to_schema( &syn::parse_str(&struct_meta.definition).unwrap(), &known_schema_names, + &struct_definitions, ) } }; @@ -83,6 +89,7 @@ pub fn generate_openapi_doc_with_metadata( &fn_item.sig, &route_meta.path, &known_schema_names, + &struct_definitions, route_meta.error_status.as_deref(), ); diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 64ad3d8..fec6caa 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -30,6 +30,7 @@ pub fn parse_function_parameter( arg: &FnArg, path_params: &[String], known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -77,7 +78,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Path, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)), + schema: Some(parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -93,7 +98,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Query, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)), + schema: Some(parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -109,7 +118,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Header, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)), + schema: Some(parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -130,7 +143,7 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Path, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(ty, known_schemas)), + schema: Some(parse_type_to_schema_ref(ty, known_schemas, struct_definitions)), example: None, }); } @@ -142,7 +155,7 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Query, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(ty, known_schemas)), + schema: Some(parse_type_to_schema_ref(ty, known_schemas, struct_definitions)), example: None, }); } @@ -345,6 +358,7 @@ fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { pub fn parse_enum_to_schema( enum_item: &syn::ItemEnum, known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> Schema { // Extract rename_all attribute from enum let rename_all = extract_rename_all(&enum_item.attrs); @@ -413,7 +427,8 @@ pub fn parse_enum_to_schema( if fields_unnamed.unnamed.len() == 1 { // Single field tuple variant let inner_type = &fields_unnamed.unnamed[0].ty; - let inner_schema = parse_type_to_schema_ref(inner_type, known_schemas); + let inner_schema = + parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions); let mut properties = BTreeMap::new(); properties.insert(variant_key.clone(), inner_schema); @@ -430,7 +445,8 @@ pub fn parse_enum_to_schema( // For OpenAPI 3.1, we use prefixItems to represent tuple arrays let mut tuple_item_schemas = Vec::new(); for field in &fields_unnamed.unnamed { - let field_schema = parse_type_to_schema_ref(&field.ty, known_schemas); + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); tuple_item_schemas.push(field_schema); } @@ -485,7 +501,8 @@ pub fn parse_enum_to_schema( }; let field_type = &field.ty; - let schema_ref = parse_type_to_schema_ref(field_type, known_schemas); + let schema_ref = + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); variant_properties.insert(field_name.clone(), schema_ref); @@ -552,10 +569,147 @@ pub fn parse_enum_to_schema( } } +/// Parse generic struct with type substitutions to OpenAPI Schema +fn parse_generic_struct_to_schema( + struct_item: &syn::ItemStruct, + type_substitutions: &HashMap, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> Schema { + let mut properties = BTreeMap::new(); + let mut required = Vec::new(); + + // Extract rename_all attribute from struct + let rename_all = extract_rename_all(&struct_item.attrs); + + match &struct_item.fields { + Fields::Named(fields_named) => { + for field in &fields_named.named { + let rust_field_name = field + .ident + .as_ref() + .map(|i| i.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // Check for field-level rename attribute first (takes precedence) + let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) { + renamed + } else { + // Apply rename_all transformation if present + rename_field(&rust_field_name, rename_all.as_deref()) + }; + + let field_type = &field.ty; + // Substitute generic type parameters with concrete types + let substituted_type = substitute_generic_type(field_type, type_substitutions); + let schema_ref = + parse_type_to_schema_ref(&substituted_type, known_schemas, struct_definitions); + + properties.insert(field_name.clone(), schema_ref); + + // Check if field is Option + let is_optional = matches!( + field_type, + Type::Path(type_path) + if type_path + .path + .segments + .first() + .map(|s| s.ident == "Option") + .unwrap_or(false) + ); + + if !is_optional { + required.push(field_name); + } + } + } + Fields::Unnamed(_) => { + // Tuple structs are not supported for now + } + Fields::Unit => { + // Unit structs have no fields + } + } + + Schema { + schema_type: Some(SchemaType::Object), + properties: if properties.is_empty() { + None + } else { + Some(properties) + }, + required: if required.is_empty() { + None + } else { + Some(required) + }, + ..Schema::object() + } +} + +/// Substitute generic type parameters with concrete types +fn substitute_generic_type( + ty: &Type, + type_substitutions: &HashMap, +) -> Type { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.is_empty() { + return ty.clone(); + } + + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Check if this is a generic parameter that needs substitution + if type_substitutions.contains_key(&ident_str) { + return type_substitutions[&ident_str].clone(); + } + + // If this type has generic arguments, recursively substitute them + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let mut new_args = syn::AngleBracketedGenericArguments { + colon2_token: args.colon2_token, + lt_token: args.lt_token, + args: syn::punctuated::Punctuated::new(), + gt_token: args.gt_token, + }; + + for arg in &args.args { + match arg { + syn::GenericArgument::Type(ty) => { + let substituted = substitute_generic_type(ty, type_substitutions); + new_args + .args + .push(syn::GenericArgument::Type(substituted)); + } + _ => { + new_args.args.push(arg.clone()); + } + } + } + + let mut new_path = type_path.clone(); + if let Some(last_seg) = new_path.path.segments.last_mut() { + last_seg.arguments = syn::PathArguments::AngleBracketed(new_args); + } + + return Type::Path(new_path); + } + + ty.clone() + } + _ => ty.clone(), + } +} + /// Parse struct definition to OpenAPI Schema pub fn parse_struct_to_schema( struct_item: &syn::ItemStruct, known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> Schema { let mut properties = BTreeMap::new(); let mut required = Vec::new(); @@ -581,7 +735,7 @@ pub fn parse_struct_to_schema( }; let field_type = &field.ty; - let schema_ref = parse_type_to_schema_ref(field_type, known_schemas); + let schema_ref = parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); properties.insert(field_name.clone(), schema_ref); @@ -627,7 +781,11 @@ pub fn parse_struct_to_schema( } /// Parse Rust type to OpenAPI SchemaRef -pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap) -> SchemaRef { +pub fn parse_type_to_schema_ref( + ty: &Type, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> SchemaRef { match ty { Type::Path(type_path) => { let path = &type_path.path; @@ -644,7 +802,8 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let inner_schema = parse_type_to_schema_ref(inner_ty, known_schemas); + let inner_schema = + parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); if ident_str == "Vec" { return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); } else { @@ -666,7 +825,7 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap { @@ -711,6 +870,61 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap + // Find the base struct definition and substitute type parameters + if let Some(base_def) = struct_definitions.get(&type_name) { + if let Ok(parsed) = syn::parse_str::(base_def) { + // Extract generic parameter names from the struct definition + let generic_params: Vec = parsed + .generics + .params + .iter() + .filter_map(|param| { + if let syn::GenericParam::Type(type_param) = param { + Some(type_param.ident.to_string()) + } else { + None + } + }) + .collect(); + + // Extract concrete type arguments + let concrete_types: Vec<&Type> = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + Some(ty) + } else { + None + } + }) + .collect(); + + // Create a type substitution map + let mut type_substitutions: std::collections::HashMap = + std::collections::HashMap::new(); + for (param_name, concrete_type) in + generic_params.iter().zip(concrete_types.iter()) + { + type_substitutions.insert(param_name.clone(), concrete_type); + } + + // Parse the struct with type substitutions + return SchemaRef::Inline(Box::new( + parse_generic_struct_to_schema( + &parsed, + &type_substitutions, + known_schemas, + struct_definitions, + ), + )); + } + } + } + // Non-generic type or generic without parameters - use reference SchemaRef::Ref(Reference::schema(&type_name)) } else { // For unknown custom types, return object schema instead of reference @@ -722,7 +936,7 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap { // Handle &T, &mut T, etc. - parse_type_to_schema_ref(&type_ref.elem, known_schemas) + parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) } _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), } @@ -732,6 +946,7 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -749,7 +964,8 @@ pub fn parse_request_body( && let syn::PathArguments::AngleBracketed(args) = &segment.arguments && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let schema = parse_type_to_schema_ref(inner_ty, known_schemas); + let schema = + parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -867,6 +1083,7 @@ fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { pub fn parse_return_type( return_type: &ReturnType, known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> BTreeMap { let mut responses = BTreeMap::new(); @@ -886,7 +1103,8 @@ pub fn parse_return_type( // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) - let ok_schema = parse_type_to_schema_ref(&ok_ty, known_schemas); + let ok_schema = + parse_type_to_schema_ref(&ok_ty, known_schemas, struct_definitions); let mut ok_content = BTreeMap::new(); ok_content.insert( "application/json".to_string(), @@ -910,7 +1128,8 @@ pub fn parse_return_type( // Check if error is (StatusCode, E) tuple if let Some((status_code, error_type)) = extract_status_code_tuple(&err_ty) { // Use the status code from the tuple - let err_schema = parse_type_to_schema_ref(&error_type, known_schemas); + let err_schema = + parse_type_to_schema_ref(&error_type, known_schemas, struct_definitions); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -933,7 +1152,8 @@ pub fn parse_return_type( // Regular error type - use default 400 // Unwrap Json if present let err_ty_unwrapped = unwrap_json(&err_ty); - let err_schema = parse_type_to_schema_ref(err_ty_unwrapped, known_schemas); + let err_schema = + parse_type_to_schema_ref(err_ty_unwrapped, known_schemas, struct_definitions); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -957,7 +1177,8 @@ pub fn parse_return_type( // Not a Result type - regular response // Unwrap Json if present let unwrapped_ty = unwrap_json(ty); - let schema = parse_type_to_schema_ref(unwrapped_ty, known_schemas); + let schema = + parse_type_to_schema_ref(unwrapped_ty, known_schemas, struct_definitions); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -988,6 +1209,7 @@ pub fn build_operation_from_function( sig: &syn::Signature, path: &str, known_schemas: &HashMap, + struct_definitions: &HashMap, error_status: Option<&[u16]>, ) -> Operation { let path_params = extract_path_parameters(path); @@ -997,15 +1219,17 @@ pub fn build_operation_from_function( // Parse function parameters for input in &sig.inputs { // Check if it's a request body (Json) - if let Some(body) = parse_request_body(input, known_schemas) { + if let Some(body) = parse_request_body(input, known_schemas, struct_definitions) { request_body = Some(body); - } else if let Some(param) = parse_function_parameter(input, &path_params, known_schemas) { + } else if let Some(param) = + parse_function_parameter(input, &path_params, known_schemas, struct_definitions) + { parameters.push(param); } } // Parse return type - may return multiple responses (for Result types) - let mut responses = parse_return_type(&sig.output, known_schemas); + let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); // Add additional error status codes from error_status attribute if let Some(status_codes) = error_status { @@ -1097,6 +1321,7 @@ mod tests { #[case("-> Result<&str, String>", "Result<&str, String>")] // Result with reference fn test_parse_return_type(#[case] return_type_str: &str, #[case] expected_type: &str) { let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); let return_type = if return_type_str.is_empty() { ReturnType::Default @@ -1108,7 +1333,7 @@ mod tests { parsed.output }; - let responses = parse_return_type(&return_type, &known_schemas); + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); match expected_type { "" => { @@ -1354,13 +1579,14 @@ mod tests { fn test_parse_return_type_with_known_schema() { let mut known_schemas = HashMap::new(); known_schemas.insert("User".to_string(), "User".to_string()); + let struct_definitions = HashMap::new(); { let return_type_str = "-> User"; let full_signature = format!("fn test() {}", return_type_str); let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); assert!(responses.contains_key("200")); @@ -1384,7 +1610,8 @@ mod tests { syn::parse_str(&full_signature).expect("Failed to parse return type"); println!("parsed: {:?}", parsed.output); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions); println!("responses: {:?}", responses); assert_eq!(responses.len(), 1); @@ -1409,13 +1636,14 @@ mod tests { let mut known_schemas = HashMap::new(); known_schemas.insert("User".to_string(), "User".to_string()); known_schemas.insert("Error".to_string(), "Error".to_string()); + let struct_definitions = HashMap::new(); let return_type_str = "-> Result"; let full_signature = format!("fn test() {}", return_type_str); let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 2); assert!(responses.contains_key("200")); @@ -1445,6 +1673,7 @@ mod tests { #[test] fn test_parse_return_type_primitive_types() { let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); let test_cases = vec![ ("-> i8", SchemaType::Integer), @@ -1466,7 +1695,8 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature) .expect(&format!("Failed to parse return type: {}", return_type_str)); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1487,13 +1717,15 @@ mod tests { #[test] fn test_parse_return_type_array() { let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); let return_type_str = "-> Vec"; let full_signature = format!("fn test() {}", return_type_str); let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1511,13 +1743,15 @@ mod tests { #[test] fn test_parse_return_type_option() { let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); let return_type_str = "-> Option"; let full_signature = format!("fn test() {}", return_type_str); let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index f8b00f9..5b6989d 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -280,6 +280,86 @@ } } }, + "/generic/generic/{value}": { + "get": { + "operationId": "generic_endpoint", + "parameters": [ + { + "name": "value", + "in": "query", + "required": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "value", + "name" + ] + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "value", + "name" + ] + } + } + } + } + } + } + }, + "/generic/generic2": { + "get": { + "operationId": "generic_endpoint2", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/TestStruct" + } + }, + "required": [ + "value", + "name" + ] + } + } + } + } + } + } + }, "/health": { "get": { "operationId": "health", @@ -1008,6 +1088,21 @@ "code" ] }, + "GenericStruct": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "value", + "name" + ] + }, "SignupRequest": { "type": "object", "properties": { diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs new file mode 100644 index 0000000..ac7fc42 --- /dev/null +++ b/examples/axum-example/src/routes/generic.rs @@ -0,0 +1,33 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; +use vespera::axum::{Json, extract::Query}; + +use crate::TestStruct; + +#[derive(Serialize, Deserialize, vespera::Schema)] +pub struct GenericStruct { + pub value: T, + pub name: String, +} + +#[vespera::route(get, path = "/generic/{value}")] +pub async fn generic_endpoint( + Query(value): Query>, +) -> Json> { + Json(GenericStruct { + value: value.value, + name: "John Doe".to_string(), + }) +} + +#[vespera::route(get, path = "/generic2")] +pub async fn generic_endpoint2() -> Json> { + Json(GenericStruct { + value: TestStruct { + name: "test".to_string(), + age: 20, + }, + name: "John Doe".to_string(), + }) +} diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index bbc7977..e068574 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -11,6 +11,7 @@ use crate::TestStruct; pub mod enums; pub mod error; pub mod foo; +pub mod generic; pub mod health; pub mod path; pub mod users; From 53f48f12f3e48a121c6484ef1b7b2ad6f984607d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 15:43:23 +0900 Subject: [PATCH 2/6] Update openapi --- crates/vespera_core/src/schema.rs | 38 ++ crates/vespera_macro/src/openapi_generator.rs | 1 + crates/vespera_macro/src/parser.rs | 493 ++++++++++-------- examples/axum-example/openapi.json | 156 ++++-- examples/axum-example/src/routes/generic.rs | 36 +- 5 files changed, 449 insertions(+), 275 deletions(-) diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 901827f..47ba6cf 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -13,6 +13,22 @@ pub enum SchemaRef { Inline(Box), } +impl SchemaRef { + /// Check if this is a reference + pub fn is_ref(&self) -> bool { + matches!(self, SchemaRef::Ref(_)) + } + + /// Get the reference path if this is a reference + pub fn ref_path(&self) -> Option<&str> { + if let SchemaRef::Ref(ref_ref) = self { + Some(&ref_ref.ref_path) + } else { + None + } + } +} + /// Reference definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Reference { @@ -77,6 +93,10 @@ pub enum StringFormat { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Schema { + /// Schema reference ($ref) - if present, other fields are ignored + #[serde(rename = "$ref")] + #[serde(skip_serializing_if = "Option::is_none")] + pub ref_path: Option, /// Schema type #[serde(rename = "type")] #[serde(skip_serializing_if = "Option::is_none")] @@ -191,12 +211,27 @@ pub struct Schema { /// External documentation reference #[serde(skip_serializing_if = "Option::is_none")] pub external_docs: Option, + + // JSON Schema 2020-12 dynamic references + /// Definitions ($defs) - reusable schema definitions + #[serde(rename = "$defs")] + #[serde(skip_serializing_if = "Option::is_none")] + pub defs: Option>, + /// Dynamic anchor ($dynamicAnchor) - defines a dynamic anchor + #[serde(rename = "$dynamicAnchor")] + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_anchor: Option, + /// Dynamic reference ($dynamicRef) - references a dynamic anchor + #[serde(rename = "$dynamicRef")] + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_ref: Option, } impl Schema { /// Create a new schema pub fn new(schema_type: SchemaType) -> Self { Self { + ref_path: None, schema_type: Some(schema_type), format: None, title: None, @@ -231,6 +266,9 @@ impl Schema { read_only: None, write_only: None, external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, } } diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 84fed24..d6f66c5 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -91,6 +91,7 @@ pub fn generate_openapi_doc_with_metadata( &known_schema_names, &struct_definitions, route_meta.error_status.as_deref(), + Some(&schemas), ); // Get or create PathItem diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index fec6caa..aff22cc 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -31,6 +31,7 @@ pub fn parse_function_parameter( path_params: &[String], known_schemas: &HashMap, struct_definitions: &HashMap, + schemas: Option<&BTreeMap>, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -78,10 +79,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Path, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref( + schema: Some(parse_type_to_schema_ref_with_schemas( inner_ty, known_schemas, struct_definitions, + schemas, )), example: None, }); @@ -98,10 +100,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Query, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref( + schema: Some(parse_type_to_schema_ref_with_schemas( inner_ty, known_schemas, struct_definitions, + schemas, )), example: None, }); @@ -118,10 +121,11 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Header, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref( + schema: Some(parse_type_to_schema_ref_with_schemas( inner_ty, known_schemas, struct_definitions, + schemas, )), example: None, }); @@ -143,7 +147,12 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Path, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(ty, known_schemas, struct_definitions)), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + schemas, + )), example: None, }); } @@ -155,7 +164,12 @@ pub fn parse_function_parameter( r#in: ParameterLocation::Query, description: None, required: Some(true), - schema: Some(parse_type_to_schema_ref(ty, known_schemas, struct_definitions)), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + schemas, + )), example: None, }); } @@ -445,8 +459,11 @@ pub fn parse_enum_to_schema( // For OpenAPI 3.1, we use prefixItems to represent tuple arrays let mut tuple_item_schemas = Vec::new(); for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + let field_schema = parse_type_to_schema_ref( + &field.ty, + known_schemas, + struct_definitions, + ); tuple_item_schemas.push(field_schema); } @@ -569,19 +586,42 @@ pub fn parse_enum_to_schema( } } -/// Parse generic struct with type substitutions to OpenAPI Schema -fn parse_generic_struct_to_schema( +/// Parse struct definition to OpenAPI Schema +pub fn parse_struct_to_schema( struct_item: &syn::ItemStruct, - type_substitutions: &HashMap, known_schemas: &HashMap, struct_definitions: &HashMap, ) -> Schema { let mut properties = BTreeMap::new(); let mut required = Vec::new(); + let mut defs = BTreeMap::new(); // Extract rename_all attribute from struct let rename_all = extract_rename_all(&struct_item.attrs); + // Check if this is a generic struct + let has_generics = !struct_item.generics.params.is_empty(); + let mut generic_params = Vec::new(); + + if has_generics { + // Extract generic parameter names + for param in &struct_item.generics.params { + if let syn::GenericParam::Type(type_param) = param { + let param_name = type_param.ident.to_string(); + generic_params.push(param_name.clone()); + + // Create $dynamicAnchor definition for each generic parameter + // Don't specify type - it will be determined by the concrete type + let anchor_schema = Schema { + dynamic_anchor: Some(param_name.clone()), + schema_type: None, // Don't assume type - concrete types will specify it + ..Schema::new(SchemaType::Object) + }; + defs.insert(param_name, anchor_schema); + } + } + } + match &struct_item.fields { Fields::Named(fields_named) => { for field in &fields_named.named { @@ -600,10 +640,39 @@ fn parse_generic_struct_to_schema( }; let field_type = &field.ty; - // Substitute generic type parameters with concrete types - let substituted_type = substitute_generic_type(field_type, type_substitutions); - let schema_ref = - parse_type_to_schema_ref(&substituted_type, known_schemas, struct_definitions); + + // If this is a generic struct and the field type is a generic parameter, + // use $dynamicRef instead of parsing the type + let schema_ref = if has_generics + && is_generic_parameter(field_type, &generic_params) + { + // Extract the generic parameter name + if let Type::Path(type_path) = field_type { + if let Some(segment) = type_path.path.segments.last() { + let param_name = segment.ident.to_string(); + if generic_params.contains(¶m_name) { + // Use $dynamicRef to reference the dynamic anchor + SchemaRef::Inline(Box::new(Schema { + dynamic_ref: Some(format!("#{}", param_name)), + schema_type: None, // Don't assume type + ..Schema::new(SchemaType::Object) + })) + } else { + parse_type_to_schema_ref( + field_type, + known_schemas, + struct_definitions, + ) + } + } else { + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) + } + } else { + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) + } + } else { + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) + }; properties.insert(field_name.clone(), schema_ref); @@ -644,147 +713,37 @@ fn parse_generic_struct_to_schema( } else { Some(required) }, + defs: if defs.is_empty() { None } else { Some(defs) }, ..Schema::object() } } -/// Substitute generic type parameters with concrete types -fn substitute_generic_type( - ty: &Type, - type_substitutions: &HashMap, -) -> Type { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.is_empty() { - return ty.clone(); - } - - let segment = path.segments.last().unwrap(); +/// Check if a type is a generic parameter +fn is_generic_parameter(ty: &Type, generic_params: &[String]) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { let ident_str = segment.ident.to_string(); - - // Check if this is a generic parameter that needs substitution - if type_substitutions.contains_key(&ident_str) { - return type_substitutions[&ident_str].clone(); - } - - // If this type has generic arguments, recursively substitute them - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - let mut new_args = syn::AngleBracketedGenericArguments { - colon2_token: args.colon2_token, - lt_token: args.lt_token, - args: syn::punctuated::Punctuated::new(), - gt_token: args.gt_token, - }; - - for arg in &args.args { - match arg { - syn::GenericArgument::Type(ty) => { - let substituted = substitute_generic_type(ty, type_substitutions); - new_args - .args - .push(syn::GenericArgument::Type(substituted)); - } - _ => { - new_args.args.push(arg.clone()); - } - } - } - - let mut new_path = type_path.clone(); - if let Some(last_seg) = new_path.path.segments.last_mut() { - last_seg.arguments = syn::PathArguments::AngleBracketed(new_args); - } - - return Type::Path(new_path); - } - - ty.clone() + return generic_params.contains(&ident_str) && segment.arguments.is_none(); } - _ => ty.clone(), } + false } -/// Parse struct definition to OpenAPI Schema -pub fn parse_struct_to_schema( - struct_item: &syn::ItemStruct, +/// Parse Rust type to OpenAPI SchemaRef +pub fn parse_type_to_schema_ref( + ty: &Type, known_schemas: &HashMap, struct_definitions: &HashMap, -) -> Schema { - let mut properties = BTreeMap::new(); - let mut required = Vec::new(); - - // Extract rename_all attribute from struct - let rename_all = extract_rename_all(&struct_item.attrs); - - match &struct_item.fields { - Fields::Named(fields_named) => { - for field in &fields_named.named { - let rust_field_name = field - .ident - .as_ref() - .map(|i| i.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - // Check for field-level rename attribute first (takes precedence) - let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) { - renamed - } else { - // Apply rename_all transformation if present - rename_field(&rust_field_name, rename_all.as_deref()) - }; - - let field_type = &field.ty; - let schema_ref = parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); - - properties.insert(field_name.clone(), schema_ref); - - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .map(|s| s.ident == "Option") - .unwrap_or(false) - ); - - if !is_optional { - required.push(field_name); - } - } - } - Fields::Unnamed(_) => { - // Tuple structs are not supported for now - } - Fields::Unit => { - // Unit structs have no fields - } - } - - Schema { - schema_type: Some(SchemaType::Object), - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - required: if required.is_empty() { - None - } else { - Some(required) - }, - ..Schema::object() - } +) -> SchemaRef { + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions, None) } -/// Parse Rust type to OpenAPI SchemaRef -pub fn parse_type_to_schema_ref( +/// Parse Rust type to OpenAPI SchemaRef with optional schemas map for resolving references +fn parse_type_to_schema_ref_with_schemas( ty: &Type, known_schemas: &HashMap, struct_definitions: &HashMap, + schemas: Option<&BTreeMap>, ) -> SchemaRef { match ty { Type::Path(type_path) => { @@ -802,8 +761,11 @@ pub fn parse_type_to_schema_ref( match ident_str.as_str() { "Vec" | "Option" => { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let inner_schema = - parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); + let inner_schema = parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); if ident_str == "Vec" { return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); } else { @@ -823,23 +785,27 @@ pub fn parse_type_to_schema_ref( Some(syn::GenericArgument::Type(_key_ty)), Some(syn::GenericArgument::Type(value_ty)), ) = (args.args.get(0), args.args.get(1)) - { - let value_schema = - parse_type_to_schema_ref(value_ty, known_schemas, struct_definitions); - // Convert SchemaRef to serde_json::Value for additional_properties - let additional_props_value = match value_schema { - SchemaRef::Ref(ref_ref) => { - serde_json::json!({ "$ref": ref_ref.ref_path }) - } - SchemaRef::Inline(schema) => serde_json::to_value(&*schema) - .unwrap_or(serde_json::json!({})), - }; - return SchemaRef::Inline(Box::new(Schema { - schema_type: Some(SchemaType::Object), - additional_properties: Some(additional_props_value), - ..Schema::object() - })); - } + { + let value_schema = parse_type_to_schema_ref( + value_ty, + known_schemas, + struct_definitions, + ); + // Convert SchemaRef to serde_json::Value for additional_properties + let additional_props_value = match value_schema { + SchemaRef::Ref(ref_ref) => { + serde_json::json!({ "$ref": ref_ref.ref_path }) + } + SchemaRef::Inline(schema) => { + serde_json::to_value(&*schema).unwrap_or(serde_json::json!({})) + } + }; + return SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + additional_properties: Some(additional_props_value), + ..Schema::object() + })); + } } _ => {} } @@ -873,7 +839,7 @@ pub fn parse_type_to_schema_ref( // Check if this is a generic type with type parameters if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { // This is a concrete generic type like GenericStruct - // Find the base struct definition and substitute type parameters + // Use $dynamicAnchor approach: $ref to base + $defs with concrete type if let Some(base_def) = struct_definitions.get(&type_name) { if let Ok(parsed) = syn::parse_str::(base_def) { // Extract generic parameter names from the struct definition @@ -903,24 +869,53 @@ pub fn parse_type_to_schema_ref( }) .collect(); - // Create a type substitution map - let mut type_substitutions: std::collections::HashMap = - std::collections::HashMap::new(); + // Create $defs with concrete types as $dynamicAnchor + let mut defs = BTreeMap::new(); for (param_name, concrete_type) in generic_params.iter().zip(concrete_types.iter()) { - type_substitutions.insert(param_name.clone(), concrete_type); + // Parse the concrete type to schema + let concrete_schema_ref = + parse_type_to_schema_ref_with_schemas( + concrete_type, + known_schemas, + struct_definitions, + schemas, + ); + + // Convert SchemaRef to Schema for $defs + // If it's a reference, use $ref directly instead of inline + let anchor_schema = match concrete_schema_ref { + SchemaRef::Ref(ref_ref) => { + // Use $ref directly in $defs + // Create a schema with $ref and $dynamicAnchor + Schema { + ref_path: Some(ref_ref.ref_path.clone()), + schema_type: None, + dynamic_anchor: Some(param_name.clone()), + ..Schema::new(SchemaType::Object) + } + } + SchemaRef::Inline(schema) => { + // For inline schemas, use them as-is + let mut s = *schema; + s.dynamic_anchor = Some(param_name.clone()); + s + } + }; + + defs.insert(param_name.clone(), anchor_schema); } - // Parse the struct with type substitutions - return SchemaRef::Inline(Box::new( - parse_generic_struct_to_schema( - &parsed, - &type_substitutions, - known_schemas, - struct_definitions, - ), - )); + // Return schema with $ref to base generic struct and $defs + return SchemaRef::Inline(Box::new(Schema { + schema_type: None, + all_of: Some(vec![SchemaRef::Ref(Reference::schema( + &type_name, + ))]), + defs: if defs.is_empty() { None } else { Some(defs) }, + ..Schema::new(SchemaType::Object) + })); } } } @@ -936,7 +931,12 @@ pub fn parse_type_to_schema_ref( } Type::Reference(type_ref) => { // Handle &T, &mut T, etc. - parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) + parse_type_to_schema_ref_with_schemas( + &type_ref.elem, + known_schemas, + struct_definitions, + schemas, + ) } _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), } @@ -947,6 +947,7 @@ pub fn parse_request_body( arg: &FnArg, known_schemas: &HashMap, struct_definitions: &HashMap, + schemas: Option<&BTreeMap>, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -964,8 +965,12 @@ pub fn parse_request_body( && let syn::PathArguments::AngleBracketed(args) = &segment.arguments && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let schema = - parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); + let schema = parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + schemas, + ); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -995,9 +1000,10 @@ fn unwrap_json(ty: &Type) -> &Type { let segment = &path.segments[0]; if segment.ident == "Json" && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return inner_ty; - } + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + return inner_ty; + } } } ty @@ -1035,16 +1041,16 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { // Get the last segment (Result) to check for generics if let Some(segment) = path.segments.last() && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && args.args.len() >= 2 - && let ( - Some(syn::GenericArgument::Type(ok_ty)), - Some(syn::GenericArgument::Type(err_ty)), - ) = (args.args.first(), args.args.get(1)) - { - // Unwrap Json from Ok type if present - let ok_ty_unwrapped = unwrap_json(ok_ty); - return Some((ok_ty_unwrapped.clone(), err_ty.clone())); - } + && args.args.len() >= 2 + && let ( + Some(syn::GenericArgument::Type(ok_ty)), + Some(syn::GenericArgument::Type(err_ty)), + ) = (args.args.first(), args.args.get(1)) + { + // Unwrap Json from Ok type if present + let ok_ty_unwrapped = unwrap_json(ok_ty); + return Some((ok_ty_unwrapped.clone(), err_ty.clone())); + } } None } @@ -1053,29 +1059,30 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { /// Returns the error type E and a default status code (400) fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { if let Type::Tuple(tuple) = err_ty - && tuple.elems.len() == 2 { - // Check if first element is StatusCode - if let Type::Path(type_path) = &tuple.elems[0] { - let path = &type_path.path; - if !path.segments.is_empty() { - let segment = &path.segments[0]; - // Check if it's StatusCode (could be qualified like axum::http::StatusCode) - let is_status_code = segment.ident == "StatusCode" - || (path.segments.len() > 1 - && path.segments.iter().any(|s| s.ident == "StatusCode")); - - if is_status_code { - // Use 400 as default status code - // The actual status code value is determined at runtime - if let Some(error_type) = tuple.elems.get(1) { - // Unwrap Json if present - let error_type_unwrapped = unwrap_json(error_type); - return Some((400, error_type_unwrapped.clone())); - } + && tuple.elems.len() == 2 + { + // Check if first element is StatusCode + if let Type::Path(type_path) = &tuple.elems[0] { + let path = &type_path.path; + if !path.segments.is_empty() { + let segment = &path.segments[0]; + // Check if it's StatusCode (could be qualified like axum::http::StatusCode) + let is_status_code = segment.ident == "StatusCode" + || (path.segments.len() > 1 + && path.segments.iter().any(|s| s.ident == "StatusCode")); + + if is_status_code { + // Use 400 as default status code + // The actual status code value is determined at runtime + if let Some(error_type) = tuple.elems.get(1) { + // Unwrap Json if present + let error_type_unwrapped = unwrap_json(error_type); + return Some((400, error_type_unwrapped.clone())); } } } } + } None } @@ -1084,6 +1091,7 @@ pub fn parse_return_type( return_type: &ReturnType, known_schemas: &HashMap, struct_definitions: &HashMap, + schemas: Option<&BTreeMap>, ) -> BTreeMap { let mut responses = BTreeMap::new(); @@ -1103,8 +1111,12 @@ pub fn parse_return_type( // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) - let ok_schema = - parse_type_to_schema_ref(&ok_ty, known_schemas, struct_definitions); + let ok_schema = parse_type_to_schema_ref_with_schemas( + &ok_ty, + known_schemas, + struct_definitions, + schemas, + ); let mut ok_content = BTreeMap::new(); ok_content.insert( "application/json".to_string(), @@ -1128,8 +1140,12 @@ pub fn parse_return_type( // Check if error is (StatusCode, E) tuple if let Some((status_code, error_type)) = extract_status_code_tuple(&err_ty) { // Use the status code from the tuple - let err_schema = - parse_type_to_schema_ref(&error_type, known_schemas, struct_definitions); + let err_schema = parse_type_to_schema_ref_with_schemas( + &error_type, + known_schemas, + struct_definitions, + schemas, + ); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -1152,8 +1168,12 @@ pub fn parse_return_type( // Regular error type - use default 400 // Unwrap Json if present let err_ty_unwrapped = unwrap_json(&err_ty); - let err_schema = - parse_type_to_schema_ref(err_ty_unwrapped, known_schemas, struct_definitions); + let err_schema = parse_type_to_schema_ref_with_schemas( + err_ty_unwrapped, + known_schemas, + struct_definitions, + schemas, + ); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -1177,8 +1197,12 @@ pub fn parse_return_type( // Not a Result type - regular response // Unwrap Json if present let unwrapped_ty = unwrap_json(ty); - let schema = - parse_type_to_schema_ref(unwrapped_ty, known_schemas, struct_definitions); + let schema = parse_type_to_schema_ref_with_schemas( + unwrapped_ty, + known_schemas, + struct_definitions, + schemas, + ); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -1211,6 +1235,7 @@ pub fn build_operation_from_function( known_schemas: &HashMap, struct_definitions: &HashMap, error_status: Option<&[u16]>, + schemas: Option<&BTreeMap>, ) -> Operation { let path_params = extract_path_parameters(path); let mut parameters = Vec::new(); @@ -1219,17 +1244,21 @@ pub fn build_operation_from_function( // Parse function parameters for input in &sig.inputs { // Check if it's a request body (Json) - if let Some(body) = parse_request_body(input, known_schemas, struct_definitions) { + if let Some(body) = parse_request_body(input, known_schemas, struct_definitions, schemas) { request_body = Some(body); - } else if let Some(param) = - parse_function_parameter(input, &path_params, known_schemas, struct_definitions) - { + } else if let Some(param) = parse_function_parameter( + input, + &path_params, + known_schemas, + struct_definitions, + schemas, + ) { parameters.push(param); } } // Parse return type - may return multiple responses (for Result types) - let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); + let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions, schemas); // Add additional error status codes from error_status attribute if let Some(status_codes) = error_status { @@ -1261,10 +1290,10 @@ pub fn build_operation_from_function( ); Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - } + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + } }); } } @@ -1333,7 +1362,7 @@ mod tests { parsed.output }; - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions, None); match expected_type { "" => { @@ -1586,7 +1615,8 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); assert_eq!(responses.len(), 1); assert!(responses.contains_key("200")); @@ -1611,7 +1641,7 @@ mod tests { println!("parsed: {:?}", parsed.output); let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); println!("responses: {:?}", responses); assert_eq!(responses.len(), 1); @@ -1643,7 +1673,8 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + let responses = + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); assert_eq!(responses.len(), 2); assert!(responses.contains_key("200")); @@ -1696,7 +1727,7 @@ mod tests { .expect(&format!("Failed to parse return type: {}", return_type_str)); let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1725,7 +1756,7 @@ mod tests { syn::parse_str(&full_signature).expect("Failed to parse return type"); let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1751,7 +1782,7 @@ mod tests { syn::parse_str(&full_signature).expect("Failed to parse return type"); let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 5b6989d..6ff5bb6 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -286,22 +286,10 @@ "parameters": [ { "name": "value", - "in": "query", + "in": "path", "required": true, "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "value", - "name" - ] + "type": "object" } } ], @@ -311,19 +299,17 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/GenericStruct" } - }, - "required": [ - "value", - "name" - ] + ], + "$defs": { + "T": { + "type": "string", + "$dynamicAnchor": "T" + } + } } } } @@ -340,19 +326,79 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/GenericStruct" + } + ], + "$defs": { + "T": { + "$ref": "#/components/schemas/TestStruct", + "$dynamicAnchor": "T" + } + } + } + } + } + } + } + } + }, + "/generic/generic3": { + "get": { + "operationId": "generic_endpoint3", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GenericStruct2" + } + ], + "$defs": { + "T": { + "$ref": "#/components/schemas/TestStruct", + "$dynamicAnchor": "T" }, - "value": { - "$ref": "#/components/schemas/TestStruct" + "T2": { + "type": "string", + "$dynamicAnchor": "T2" } - }, - "required": [ - "value", - "name" - ] + } + } + } + } + } + } + } + }, + "/generic/generic4": { + "get": { + "operationId": "generic_endpoint4", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/GenericStruct2" + } + ], + "$defs": { + "T": { + "type": "boolean", + "$dynamicAnchor": "T" + }, + "T2": { + "type": "boolean", + "$dynamicAnchor": "T2" + } + } } } } @@ -1095,13 +1141,45 @@ "type": "string" }, "value": { - "type": "object" + "$dynamicRef": "#T" } }, "required": [ "value", "name" - ] + ], + "$defs": { + "T": { + "$dynamicAnchor": "T" + } + } + }, + "GenericStruct2": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$dynamicRef": "#T" + }, + "value2": { + "$dynamicRef": "#T2" + } + }, + "required": [ + "value", + "name", + "value2" + ], + "$defs": { + "T": { + "$dynamicAnchor": "T" + }, + "T2": { + "$dynamicAnchor": "T2" + } + } }, "SignupRequest": { "type": "object", diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs index ac7fc42..dde5cc2 100644 --- a/examples/axum-example/src/routes/generic.rs +++ b/examples/axum-example/src/routes/generic.rs @@ -1,22 +1,27 @@ -use std::collections::{BTreeMap, HashMap}; - use serde::{Deserialize, Serialize}; use vespera::axum::{Json, extract::Query}; use crate::TestStruct; -#[derive(Serialize, Deserialize, vespera::Schema)] +#[derive(Serialize, vespera::Schema)] pub struct GenericStruct { pub value: T, pub name: String, } +#[derive(Serialize, vespera::Schema)] +pub struct GenericStruct2 { + pub value: T, + pub name: String, + pub value2: T2, +} + #[vespera::route(get, path = "/generic/{value}")] pub async fn generic_endpoint( - Query(value): Query>, + vespera::axum::extract::Path(value): vespera::axum::extract::Path, ) -> Json> { Json(GenericStruct { - value: value.value, + value, name: "John Doe".to_string(), }) } @@ -31,3 +36,24 @@ pub async fn generic_endpoint2() -> Json> { name: "John Doe".to_string(), }) } + +#[vespera::route(get, path = "/generic3")] +pub async fn generic_endpoint3() -> Json> { + Json(GenericStruct2 { + value: TestStruct { + name: "test".to_string(), + age: 20, + }, + value2: "test2".to_string(), + name: "John Doe".to_string(), + }) +} + +#[vespera::route(get, path = "/generic4")] +pub async fn generic_endpoint4() -> Json> { + Json(GenericStruct2 { + value: true, + value2: false, + name: "John Doe".to_string(), + }) +} From d53e28953d52e61c244187f784cd7849019c7548 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 15:51:30 +0900 Subject: [PATCH 3/6] Fix Path issue --- crates/vespera_macro/src/parser.rs | 11 ++++++++--- examples/axum-example/openapi.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index aff22cc..8372752 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -54,10 +54,12 @@ pub fn parse_function_parameter( }; // Check for common Axum extractors first (before checking path_params) + // Handle both Path and vespera::axum::extract::Path by checking the last segment if let Type::Path(type_path) = ty.as_ref() { let path = &type_path.path; if !path.segments.is_empty() { - let segment = &path.segments[0]; + // Check the last segment (handles both Path and vespera::axum::extract::Path) + let segment = path.segments.last().unwrap(); let ident_str = segment.ident.to_string(); match ident_str.as_str() { @@ -958,7 +960,8 @@ pub fn parse_request_body( return None; } - let segment = &path.segments[0]; + // Check the last segment (handles both Json and vespera::axum::Json) + let segment = path.segments.last().unwrap(); let ident_str = segment.ident.to_string(); if ident_str == "Json" @@ -993,11 +996,13 @@ pub fn parse_request_body( } /// Unwrap Json to get T +/// Handles both Json and vespera::axum::Json by checking the last segment fn unwrap_json(ty: &Type) -> &Type { if let Type::Path(type_path) = ty { let path = &type_path.path; if !path.segments.is_empty() { - let segment = &path.segments[0]; + // Check the last segment (handles both Json and vespera::axum::Json) + let segment = path.segments.last().unwrap(); if segment.ident == "Json" && let syn::PathArguments::AngleBracketed(args) = &segment.arguments && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 6ff5bb6..57e8215 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -289,7 +289,7 @@ "in": "path", "required": true, "schema": { - "type": "object" + "type": "string" } } ], From dc827147c9c349bbecc24874d9b8aafaa52ed7dc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 17:03:55 +0900 Subject: [PATCH 4/6] Support generic --- crates/vespera_macro/src/openapi_generator.rs | 1 - crates/vespera_macro/src/parser.rs | 279 ++++++------------ crates/vespera_macro/src/route/utils.rs | 7 +- examples/axum-example/openapi.json | 123 ++++---- examples/axum-example/src/routes/generic.rs | 4 +- 5 files changed, 162 insertions(+), 252 deletions(-) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index d6f66c5..84fed24 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -91,7 +91,6 @@ pub fn generate_openapi_doc_with_metadata( &known_schema_names, &struct_definitions, route_meta.error_status.as_deref(), - Some(&schemas), ); // Get or create PathItem diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 8372752..4c28ae1 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -31,7 +31,6 @@ pub fn parse_function_parameter( path_params: &[String], known_schemas: &HashMap, struct_definitions: &HashMap, - schemas: Option<&BTreeMap>, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -85,7 +84,6 @@ pub fn parse_function_parameter( inner_ty, known_schemas, struct_definitions, - schemas, )), example: None, }); @@ -106,7 +104,6 @@ pub fn parse_function_parameter( inner_ty, known_schemas, struct_definitions, - schemas, )), example: None, }); @@ -127,7 +124,6 @@ pub fn parse_function_parameter( inner_ty, known_schemas, struct_definitions, - schemas, )), example: None, }); @@ -153,7 +149,6 @@ pub fn parse_function_parameter( ty, known_schemas, struct_definitions, - schemas, )), example: None, }); @@ -170,7 +165,6 @@ pub fn parse_function_parameter( ty, known_schemas, struct_definitions, - schemas, )), example: None, }); @@ -596,34 +590,10 @@ pub fn parse_struct_to_schema( ) -> Schema { let mut properties = BTreeMap::new(); let mut required = Vec::new(); - let mut defs = BTreeMap::new(); // Extract rename_all attribute from struct let rename_all = extract_rename_all(&struct_item.attrs); - // Check if this is a generic struct - let has_generics = !struct_item.generics.params.is_empty(); - let mut generic_params = Vec::new(); - - if has_generics { - // Extract generic parameter names - for param in &struct_item.generics.params { - if let syn::GenericParam::Type(type_param) = param { - let param_name = type_param.ident.to_string(); - generic_params.push(param_name.clone()); - - // Create $dynamicAnchor definition for each generic parameter - // Don't specify type - it will be determined by the concrete type - let anchor_schema = Schema { - dynamic_anchor: Some(param_name.clone()), - schema_type: None, // Don't assume type - concrete types will specify it - ..Schema::new(SchemaType::Object) - }; - defs.insert(param_name, anchor_schema); - } - } - } - match &struct_item.fields { Fields::Named(fields_named) => { for field in &fields_named.named { @@ -643,38 +613,8 @@ pub fn parse_struct_to_schema( let field_type = &field.ty; - // If this is a generic struct and the field type is a generic parameter, - // use $dynamicRef instead of parsing the type - let schema_ref = if has_generics - && is_generic_parameter(field_type, &generic_params) - { - // Extract the generic parameter name - if let Type::Path(type_path) = field_type { - if let Some(segment) = type_path.path.segments.last() { - let param_name = segment.ident.to_string(); - if generic_params.contains(¶m_name) { - // Use $dynamicRef to reference the dynamic anchor - SchemaRef::Inline(Box::new(Schema { - dynamic_ref: Some(format!("#{}", param_name)), - schema_type: None, // Don't assume type - ..Schema::new(SchemaType::Object) - })) - } else { - parse_type_to_schema_ref( - field_type, - known_schemas, - struct_definitions, - ) - } - } else { - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) - } - } else { - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) - } - } else { - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions) - }; + let schema_ref = + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); properties.insert(field_name.clone(), schema_ref); @@ -715,20 +655,42 @@ pub fn parse_struct_to_schema( } else { Some(required) }, - defs: if defs.is_empty() { None } else { Some(defs) }, ..Schema::object() } } -/// Check if a type is a generic parameter -fn is_generic_parameter(ty: &Type, generic_params: &[String]) -> bool { - if let Type::Path(type_path) = ty { - if let Some(segment) = type_path.path.segments.last() { - let ident_str = segment.ident.to_string(); - return generic_params.contains(&ident_str) && segment.arguments.is_none(); +/// Substitute generic parameters in a type with concrete types +/// Uses quote! to regenerate the type with substitutions +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(); + } } } - false + + // For complex types, use quote! to regenerate with substitutions + let tokens = quote::quote! { #ty }; + let mut new_tokens = tokens.to_string(); + + // 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); + } + + // Parse the substituted type + syn::parse_str::(&new_tokens).unwrap_or_else(|_| ty.clone()) } /// Parse Rust type to OpenAPI SchemaRef @@ -737,7 +699,7 @@ pub fn parse_type_to_schema_ref( known_schemas: &HashMap, struct_definitions: &HashMap, ) -> SchemaRef { - parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions, None) + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions) } /// Parse Rust type to OpenAPI SchemaRef with optional schemas map for resolving references @@ -745,7 +707,6 @@ fn parse_type_to_schema_ref_with_schemas( ty: &Type, known_schemas: &HashMap, struct_definitions: &HashMap, - schemas: Option<&BTreeMap>, ) -> SchemaRef { match ty { Type::Path(type_path) => { @@ -841,83 +802,60 @@ fn parse_type_to_schema_ref_with_schemas( // Check if this is a generic type with type parameters if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { // This is a concrete generic type like GenericStruct - // Use $dynamicAnchor approach: $ref to base + $defs with concrete type - if let Some(base_def) = struct_definitions.get(&type_name) { - if let Ok(parsed) = syn::parse_str::(base_def) { - // Extract generic parameter names from the struct definition - let generic_params: Vec = parsed - .generics - .params - .iter() - .filter_map(|param| { - if let syn::GenericParam::Type(type_param) = param { - Some(type_param.ident.to_string()) - } else { - None - } - }) - .collect(); - - // Extract concrete type arguments - let concrete_types: Vec<&Type> = args - .args - .iter() - .filter_map(|arg| { - if let syn::GenericArgument::Type(ty) = arg { - Some(ty) - } else { - None - } - }) - .collect(); - - // Create $defs with concrete types as $dynamicAnchor - let mut defs = BTreeMap::new(); - for (param_name, concrete_type) in - generic_params.iter().zip(concrete_types.iter()) - { - // Parse the concrete type to schema - let concrete_schema_ref = - parse_type_to_schema_ref_with_schemas( - concrete_type, - known_schemas, - struct_definitions, - schemas, + // Inline the schema by substituting generic parameters with concrete types + if let Some(base_def) = struct_definitions.get(&type_name) + && let Ok(mut parsed) = syn::parse_str::(base_def) + { + // Extract generic parameter names from the struct definition + let generic_params: Vec = parsed + .generics + .params + .iter() + .filter_map(|param| { + if let syn::GenericParam::Type(type_param) = param { + Some(type_param.ident.to_string()) + } else { + None + } + }) + .collect(); + + // Extract concrete type arguments + let concrete_types: Vec<&Type> = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + Some(ty) + } else { + None + } + }) + .collect(); + + // Substitute generic parameters with concrete types in all fields + if generic_params.len() == concrete_types.len() { + if let syn::Fields::Named(fields_named) = &mut parsed.fields { + for field in &mut fields_named.named { + field.ty = substitute_type( + &field.ty, + &generic_params, + &concrete_types, ); - - // Convert SchemaRef to Schema for $defs - // If it's a reference, use $ref directly instead of inline - let anchor_schema = match concrete_schema_ref { - SchemaRef::Ref(ref_ref) => { - // Use $ref directly in $defs - // Create a schema with $ref and $dynamicAnchor - Schema { - ref_path: Some(ref_ref.ref_path.clone()), - schema_type: None, - dynamic_anchor: Some(param_name.clone()), - ..Schema::new(SchemaType::Object) - } - } - SchemaRef::Inline(schema) => { - // For inline schemas, use them as-is - let mut s = *schema; - s.dynamic_anchor = Some(param_name.clone()); - s - } - }; - - defs.insert(param_name.clone(), anchor_schema); + } } - // Return schema with $ref to base generic struct and $defs - return SchemaRef::Inline(Box::new(Schema { - schema_type: None, - all_of: Some(vec![SchemaRef::Ref(Reference::schema( - &type_name, - ))]), - defs: if defs.is_empty() { None } else { Some(defs) }, - ..Schema::new(SchemaType::Object) - })); + // Remove generics from the struct (it's now concrete) + parsed.generics.params.clear(); + parsed.generics.where_clause = None; + + // Parse the substituted struct to schema (inline) + let schema = parse_struct_to_schema( + &parsed, + known_schemas, + struct_definitions, + ); + return SchemaRef::Inline(Box::new(schema)); } } } @@ -933,12 +871,7 @@ fn parse_type_to_schema_ref_with_schemas( } Type::Reference(type_ref) => { // Handle &T, &mut T, etc. - parse_type_to_schema_ref_with_schemas( - &type_ref.elem, - known_schemas, - struct_definitions, - schemas, - ) + parse_type_to_schema_ref_with_schemas(&type_ref.elem, known_schemas, struct_definitions) } _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), } @@ -949,7 +882,6 @@ pub fn parse_request_body( arg: &FnArg, known_schemas: &HashMap, struct_definitions: &HashMap, - schemas: Option<&BTreeMap>, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -972,7 +904,6 @@ pub fn parse_request_body( inner_ty, known_schemas, struct_definitions, - schemas, ); let mut content = BTreeMap::new(); content.insert( @@ -1096,7 +1027,6 @@ pub fn parse_return_type( return_type: &ReturnType, known_schemas: &HashMap, struct_definitions: &HashMap, - schemas: Option<&BTreeMap>, ) -> BTreeMap { let mut responses = BTreeMap::new(); @@ -1120,7 +1050,6 @@ pub fn parse_return_type( &ok_ty, known_schemas, struct_definitions, - schemas, ); let mut ok_content = BTreeMap::new(); ok_content.insert( @@ -1149,7 +1078,6 @@ pub fn parse_return_type( &error_type, known_schemas, struct_definitions, - schemas, ); let mut err_content = BTreeMap::new(); err_content.insert( @@ -1177,7 +1105,6 @@ pub fn parse_return_type( err_ty_unwrapped, known_schemas, struct_definitions, - schemas, ); let mut err_content = BTreeMap::new(); err_content.insert( @@ -1206,7 +1133,6 @@ pub fn parse_return_type( unwrapped_ty, known_schemas, struct_definitions, - schemas, ); let mut content = BTreeMap::new(); content.insert( @@ -1240,7 +1166,6 @@ pub fn build_operation_from_function( known_schemas: &HashMap, struct_definitions: &HashMap, error_status: Option<&[u16]>, - schemas: Option<&BTreeMap>, ) -> Operation { let path_params = extract_path_parameters(path); let mut parameters = Vec::new(); @@ -1249,21 +1174,17 @@ pub fn build_operation_from_function( // Parse function parameters for input in &sig.inputs { // Check if it's a request body (Json) - if let Some(body) = parse_request_body(input, known_schemas, struct_definitions, schemas) { + if let Some(body) = parse_request_body(input, known_schemas, struct_definitions) { request_body = Some(body); - } else if let Some(param) = parse_function_parameter( - input, - &path_params, - known_schemas, - struct_definitions, - schemas, - ) { + } else if let Some(param) = + parse_function_parameter(input, &path_params, known_schemas, struct_definitions) + { parameters.push(param); } } // Parse return type - may return multiple responses (for Result types) - let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions, schemas); + let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); // Add additional error status codes from error_status attribute if let Some(status_codes) = error_status { @@ -1367,7 +1288,7 @@ mod tests { parsed.output }; - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); match expected_type { "" => { @@ -1620,8 +1541,7 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); assert!(responses.contains_key("200")); @@ -1645,8 +1565,7 @@ mod tests { syn::parse_str(&full_signature).expect("Failed to parse return type"); println!("parsed: {:?}", parsed.output); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); println!("responses: {:?}", responses); assert_eq!(responses.len(), 1); @@ -1678,8 +1597,7 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 2); assert!(responses.contains_key("200")); @@ -1731,8 +1649,7 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature) .expect(&format!("Failed to parse return type: {}", return_type_str)); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1760,8 +1677,7 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1786,8 +1702,7 @@ mod tests { let parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); - let responses = - parse_return_type(&parsed.output, &known_schemas, &struct_definitions, None); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 32fb3c1..fd4449a 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -53,9 +53,10 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { lit: syn::Lit::Int(lit_int), .. }) = elem - && let Ok(code) = lit_int.base10_parse::() { - status_codes.push(code); - } + && let Ok(code) = lit_int.base10_parse::() + { + status_codes.push(code); + } } if status_codes.is_empty() { None diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 57e8215..9413ced 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -299,17 +299,19 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/GenericStruct" - } - ], - "$defs": { - "T": { - "type": "string", - "$dynamicAnchor": "T" + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" } - } + }, + "required": [ + "value", + "name" + ] } } } @@ -326,17 +328,19 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/GenericStruct" - } - ], - "$defs": { - "T": { - "$ref": "#/components/schemas/TestStruct", - "$dynamicAnchor": "T" + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/TestStruct" } - } + }, + "required": [ + "value", + "name" + ] } } } @@ -353,21 +357,23 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/GenericStruct2" - } - ], - "$defs": { - "T": { - "$ref": "#/components/schemas/TestStruct", - "$dynamicAnchor": "T" + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/TestStruct" }, - "T2": { - "type": "string", - "$dynamicAnchor": "T2" + "value2": { + "type": "string" } - } + }, + "required": [ + "value", + "name", + "value2" + ] } } } @@ -384,21 +390,23 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/GenericStruct2" - } - ], - "$defs": { - "T": { - "type": "boolean", - "$dynamicAnchor": "T" + "type": "object", + "properties": { + "name": { + "type": "string" }, - "T2": { - "type": "boolean", - "$dynamicAnchor": "T2" + "value": { + "type": "boolean" + }, + "value2": { + "type": "boolean" } - } + }, + "required": [ + "value", + "name", + "value2" + ] } } } @@ -1141,18 +1149,13 @@ "type": "string" }, "value": { - "$dynamicRef": "#T" + "type": "object" } }, "required": [ "value", "name" - ], - "$defs": { - "T": { - "$dynamicAnchor": "T" - } - } + ] }, "GenericStruct2": { "type": "object", @@ -1161,25 +1164,17 @@ "type": "string" }, "value": { - "$dynamicRef": "#T" + "type": "object" }, "value2": { - "$dynamicRef": "#T2" + "type": "object" } }, "required": [ "value", "name", "value2" - ], - "$defs": { - "T": { - "$dynamicAnchor": "T" - }, - "T2": { - "$dynamicAnchor": "T2" - } - } + ] }, "SignupRequest": { "type": "object", diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs index dde5cc2..45857d0 100644 --- a/examples/axum-example/src/routes/generic.rs +++ b/examples/axum-example/src/routes/generic.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; -use vespera::axum::{Json, extract::Query}; +use serde::Serialize; +use vespera::axum::Json; use crate::TestStruct; From 475a3b45cf02311b85f0e88b0daae8c37a13a59b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 17:08:14 +0900 Subject: [PATCH 5/6] Support generic --- .../snapshots/integration_test__openapi.snap | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 1c04f7b..be71a12 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -284,6 +284,140 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/generic/generic/{value}": { + "get": { + "operationId": "generic_endpoint", + "parameters": [ + { + "name": "value", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "value", + "name" + ] + } + } + } + } + } + } + }, + "/generic/generic2": { + "get": { + "operationId": "generic_endpoint2", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/TestStruct" + } + }, + "required": [ + "value", + "name" + ] + } + } + } + } + } + } + }, + "/generic/generic3": { + "get": { + "operationId": "generic_endpoint3", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/TestStruct" + }, + "value2": { + "type": "string" + } + }, + "required": [ + "value", + "name", + "value2" + ] + } + } + } + } + } + } + }, + "/generic/generic4": { + "get": { + "operationId": "generic_endpoint4", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "boolean" + }, + "value2": { + "type": "boolean" + } + }, + "required": [ + "value", + "name", + "value2" + ] + } + } + } + } + } + } + }, "/health": { "get": { "operationId": "health", @@ -1012,6 +1146,40 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "code" ] }, + "GenericStruct": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "value", + "name" + ] + }, + "GenericStruct2": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "object" + }, + "value2": { + "type": "object" + } + }, + "required": [ + "value", + "name", + "value2" + ] + }, "SignupRequest": { "type": "object", "properties": { From ab7eca424764c50049283d3be136b9e24f6e2934 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 17:08:44 +0900 Subject: [PATCH 6/6] Support generic --- .changepacks/changepack_log_x_7R8J07eMCRA-QQM_Bxj.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_x_7R8J07eMCRA-QQM_Bxj.json diff --git a/.changepacks/changepack_log_x_7R8J07eMCRA-QQM_Bxj.json b/.changepacks/changepack_log_x_7R8J07eMCRA-QQM_Bxj.json new file mode 100644 index 0000000..1402151 --- /dev/null +++ b/.changepacks/changepack_log_x_7R8J07eMCRA-QQM_Bxj.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Support generic","date":"2025-12-04T08:08:40.530199Z"} \ No newline at end of file