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 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_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/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..4c28ae1 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, @@ -52,10 +53,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() { @@ -77,7 +80,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_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -93,7 +100,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_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -109,7 +120,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_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -130,7 +145,11 @@ 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_with_schemas( + ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -142,7 +161,11 @@ 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_with_schemas( + ty, + known_schemas, + struct_definitions, + )), example: None, }); } @@ -345,6 +368,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 +437,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 +455,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); + let field_schema = parse_type_to_schema_ref( + &field.ty, + known_schemas, + struct_definitions, + ); tuple_item_schemas.push(field_schema); } @@ -485,7 +514,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); @@ -556,6 +586,7 @@ pub fn parse_enum_to_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 +612,9 @@ 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); @@ -626,8 +659,55 @@ pub fn parse_struct_to_schema( } } +/// 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(); + } + } + } + + // 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 -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 { + 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 +fn parse_type_to_schema_ref_with_schemas( + ty: &Type, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> SchemaRef { match ty { Type::Path(type_path) => { let path = &type_path.path; @@ -644,7 +724,11 @@ 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 { @@ -664,23 +748,27 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap { - 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() + })); + } } _ => {} } @@ -711,6 +799,67 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap + // 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, + ); + } + } + + // 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)); + } + } + } + // 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 +871,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_with_schemas(&type_ref.elem, known_schemas, struct_definitions) } _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), } @@ -732,6 +881,7 @@ pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap, + struct_definitions: &HashMap, ) -> Option { match arg { FnArg::Receiver(_) => None, @@ -742,14 +892,19 @@ 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" && 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_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + ); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -772,16 +927,19 @@ 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() { - return inner_ty; - } + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + return inner_ty; + } } } ty @@ -819,16 +977,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 } @@ -837,29 +995,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 } @@ -867,6 +1026,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 +1046,11 @@ 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_with_schemas( + &ok_ty, + known_schemas, + struct_definitions, + ); let mut ok_content = BTreeMap::new(); ok_content.insert( "application/json".to_string(), @@ -910,7 +1074,11 @@ 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_with_schemas( + &error_type, + known_schemas, + struct_definitions, + ); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -933,7 +1101,11 @@ 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_with_schemas( + err_ty_unwrapped, + known_schemas, + struct_definitions, + ); let mut err_content = BTreeMap::new(); err_content.insert( "application/json".to_string(), @@ -957,7 +1129,11 @@ 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_with_schemas( + unwrapped_ty, + known_schemas, + struct_definitions, + ); let mut content = BTreeMap::new(); content.insert( "application/json".to_string(), @@ -988,6 +1164,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 +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) { + 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 { @@ -1037,10 +1216,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), + } }); } } @@ -1097,6 +1276,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 +1288,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 +1534,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 +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); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); println!("responses: {:?}", responses); assert_eq!(responses.len(), 1); @@ -1409,13 +1590,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 +1627,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 +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); + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); assert_eq!(responses.len(), 1); let response = responses.get("200").unwrap(); @@ -1487,13 +1670,14 @@ 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 +1695,14 @@ 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/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 f8b00f9..9413ced 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -280,6 +280,140 @@ } } }, + "/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", @@ -1008,6 +1142,40 @@ "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": { diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs new file mode 100644 index 0000000..45857d0 --- /dev/null +++ b/examples/axum-example/src/routes/generic.rs @@ -0,0 +1,59 @@ +use serde::Serialize; +use vespera::axum::Json; + +use crate::TestStruct; + +#[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( + vespera::axum::extract::Path(value): vespera::axum::extract::Path, +) -> Json> { + Json(GenericStruct { + 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(), + }) +} + +#[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(), + }) +} 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; 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": {