diff --git a/.changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json b/.changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json new file mode 100644 index 0000000..aeb50bd --- /dev/null +++ b/.changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Fix query issue","date":"2025-12-04T10:43:01.304353300Z"} \ No newline at end of file diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 4c28ae1..0fadbf6 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -25,13 +25,15 @@ pub fn extract_path_parameters(path: &str) -> Vec { params } -/// Analyze function parameter and convert to OpenAPI Parameter +/// Analyze function parameter and convert to OpenAPI Parameter(s) +/// Returns None if parameter should be ignored (e.g., Query>) +/// Returns Some(Vec) with one or more parameters pub fn parse_function_parameter( arg: &FnArg, path_params: &[String], known_schemas: &HashMap, struct_definitions: &HashMap, -) -> Option { +) -> Option> { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { pat, ty, .. }) => { @@ -75,7 +77,7 @@ pub fn parse_function_parameter( // Otherwise use the parameter name from the pattern param_name }; - return Some(Parameter { + return Some(vec![Parameter { name, r#in: ParameterLocation::Path, description: None, @@ -86,7 +88,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } } "Query" => { @@ -95,7 +97,28 @@ pub fn parse_function_parameter( && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return Some(Parameter { + // Check if it's HashMap or BTreeMap - ignore these + if is_map_type(inner_ty) { + return None; + } + + // Check if it's a struct - expand to individual parameters + if let Some(struct_params) = parse_query_struct_to_parameters( + inner_ty, + known_schemas, + struct_definitions, + ) { + return Some(struct_params); + } + + // Check if it's a known type (primitive or known schema) + // If unknown, don't add parameter + if !is_known_type(inner_ty, known_schemas, struct_definitions) { + return None; + } + + // Otherwise, treat as single parameter + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Query, description: None, @@ -106,7 +129,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } } "Header" => { @@ -115,7 +138,7 @@ pub fn parse_function_parameter( && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return Some(Parameter { + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Header, description: None, @@ -126,7 +149,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } } "Json" => { @@ -140,7 +163,7 @@ pub fn parse_function_parameter( // Check if it's a path parameter (by name match) - for non-extractor cases if path_params.contains(¶m_name) { - return Some(Parameter { + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, @@ -151,12 +174,12 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } // Check if it's a primitive type (direct parameter) if is_primitive_type(ty.as_ref()) { - return Some(Parameter { + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Query, description: None, @@ -167,7 +190,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } None @@ -175,6 +198,206 @@ pub fn parse_function_parameter( } } +/// Check if a type is HashMap or BTreeMap +fn is_map_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if !path.segments.is_empty() { + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + return ident_str == "HashMap" || ident_str == "BTreeMap"; + } + } + false +} + +/// Check if a type is a known type (primitive, known schema, or struct definition) +fn is_known_type( + ty: &Type, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> bool { + // Check if it's a primitive type + if is_primitive_type(ty) { + return true; + } + + // Check if it's a known struct + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return false; + } + + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Get type name (handle both simple and qualified paths) + let type_name = if path.segments.len() > 1 { + ident_str.clone() + } else { + ident_str.clone() + }; + + // Check if it's in struct_definitions or known_schemas + if struct_definitions.contains_key(&type_name) || known_schemas.contains_key(&type_name) { + return true; + } + + // Check for generic types like Vec, Option - recursively check inner type + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + match ident_str.as_str() { + "Vec" | "Option" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return is_known_type(inner_ty, known_schemas, struct_definitions); + } + } + _ => {} + } + } + } + + false +} + +/// Parse struct fields to individual query parameters +/// Returns None if the type is not a struct or cannot be parsed +fn parse_query_struct_to_parameters( + ty: &Type, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> Option> { + // Check if it's a known struct + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return None; + } + + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Get type name (handle both simple and qualified paths) + let type_name = if path.segments.len() > 1 { + ident_str.clone() + } else { + ident_str.clone() + }; + + // Check if it's a known struct + if let Some(struct_def) = struct_definitions.get(&type_name) { + if let Ok(struct_item) = syn::parse_str::(struct_def) { + let mut parameters = Vec::new(); + + // Extract rename_all attribute from struct + let rename_all = extract_rename_all(&struct_item.attrs); + + if let syn::Fields::Named(fields_named) = &struct_item.fields { + 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; + + // 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) + ); + + // Parse field type to schema (inline, not ref) + // For Query parameters, we need inline schemas, not refs + let mut field_schema = parse_type_to_schema_ref_with_schemas( + field_type, + known_schemas, + struct_definitions, + ); + + // Convert ref to inline if needed (Query parameters should not use refs) + // If it's a ref to a known struct, get the struct definition and inline it + if let SchemaRef::Ref(ref_ref) = &field_schema { + // Try to extract type name from ref path (e.g., "#/components/schemas/User" -> "User") + if let Some(type_name) = + ref_ref.ref_path.strip_prefix("#/components/schemas/") + { + if let Some(struct_def) = struct_definitions.get(type_name) { + if let Ok(nested_struct_item) = + syn::parse_str::(struct_def) + { + // Parse the nested struct to schema (inline) + let nested_schema = parse_struct_to_schema( + &nested_struct_item, + known_schemas, + struct_definitions, + ); + field_schema = SchemaRef::Inline(Box::new(nested_schema)); + } + } + } + } + + // If it's Option, make it nullable + let final_schema = if is_optional { + if let SchemaRef::Inline(mut schema) = field_schema { + schema.nullable = Some(true); + SchemaRef::Inline(schema) + } else { + // If still a ref, convert to inline object with nullable + SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + nullable: Some(true), + ..Schema::object() + })) + } + } else { + // If it's still a ref, convert to inline object + match field_schema { + SchemaRef::Ref(_) => { + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + SchemaRef::Inline(schema) => SchemaRef::Inline(schema), + } + }; + + let required = !is_optional; + + parameters.push(Parameter { + name: field_name, + r#in: ParameterLocation::Query, + description: None, + required: Some(required), + schema: Some(final_schema), + example: None, + }); + } + } + + if !parameters.is_empty() { + return Some(parameters); + } + } + } + } + None +} + /// Check if a type is a primitive type fn is_primitive_type(ty: &Type) -> bool { match ty { @@ -1176,10 +1399,10 @@ pub fn build_operation_from_function( // Check if it's a request body (Json) if let Some(body) = parse_request_body(input, known_schemas, struct_definitions) { request_body = Some(body); - } else if let Some(param) = + } else if let Some(params) = parse_function_parameter(input, &path_params, known_schemas, struct_definitions) { - parameters.push(param); + parameters.extend(params); } } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 9413ced..513bf97 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -453,16 +453,28 @@ "operationId": "mod_file_with_map_query", "parameters": [ { - "name": "_query", + "name": "name", "in": "query", "required": true, "schema": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true } } ], @@ -480,6 +492,23 @@ } } }, + "/no-schema-query": { + "get": { + "operationId": "mod_file_with_no_schema_query", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/path/prefix/{var}": { "get": { "operationId": "prefix_variable", @@ -566,11 +595,19 @@ "operationId": "mod_file_with_struct_query", "parameters": [ { - "name": "query", + "name": "name", "in": "query", "required": true, "schema": { - "$ref": "#/components/schemas/StructQuery" + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" } } ], @@ -593,11 +630,19 @@ "operationId": "mod_file_with_test_struct", "parameters": [ { - "name": "query", + "name": "name", "in": "query", "required": true, "schema": { - "$ref": "#/components/schemas/TestStruct" + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" } } ], @@ -1176,6 +1221,25 @@ "value2" ] }, + "MapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + }, "SignupRequest": { "type": "object", "properties": { diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index ca27430..cb35618 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -27,10 +27,32 @@ pub async fn mod_file_endpoint() -> &'static str { "mod file endpoint" } +#[derive(Deserialize, Schema, Debug)] +pub struct MapQuery { + pub name: String, + pub age: u32, + pub optional_age: Option, +} #[vespera::route(get, path = "/map-query")] -pub async fn mod_file_with_map_query( - Query(_query): Query>, -) -> &'static str { +pub async fn mod_file_with_map_query(Query(query): Query) -> &'static str { + println!("map query: {:?}", query.age); + println!("map query: {:?}", query.name); + println!("map query: {:?}", query.optional_age); + "mod file endpoint" +} + +#[derive(Deserialize, Debug)] +pub struct NoSchemaQuery { + pub name: String, + pub age: u32, + pub optional_age: Option, +} + +#[vespera::route(get, path = "/no-schema-query")] +pub async fn mod_file_with_no_schema_query(Query(query): Query) -> &'static str { + println!("no schema query: {:?}", query.age); + println!("no schema query: {:?}", query.name); + println!("no schema query: {:?}", query.optional_age); "mod file endpoint" } diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index be71a12..3a8be19 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -457,16 +457,28 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "operationId": "mod_file_with_map_query", "parameters": [ { - "name": "_query", + "name": "name", "in": "query", "required": true, "schema": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": { - "type": "string" - } + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "optional_age", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "nullable": true } } ], @@ -484,6 +496,23 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/no-schema-query": { + "get": { + "operationId": "mod_file_with_no_schema_query", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/path/prefix/{var}": { "get": { "operationId": "prefix_variable", @@ -570,11 +599,19 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "operationId": "mod_file_with_struct_query", "parameters": [ { - "name": "query", + "name": "name", "in": "query", "required": true, "schema": { - "$ref": "#/components/schemas/StructQuery" + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" } } ], @@ -597,11 +634,19 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "operationId": "mod_file_with_test_struct", "parameters": [ { - "name": "query", + "name": "name", "in": "query", "required": true, "schema": { - "$ref": "#/components/schemas/TestStruct" + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer" } } ], @@ -1180,6 +1225,25 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "value2" ] }, + "MapQuery": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "optional_age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "age" + ] + }, "SignupRequest": { "type": "object", "properties": {