From 5ea33c6bbb4c8bb58c09e24b559dea747ec62aa3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 19:40:11 +0900 Subject: [PATCH 1/4] Fix query --- crates/vespera_macro/src/parser.rs | 192 ++++++++++++++++++++++-- examples/axum-example/openapi.json | 94 ++++++++++-- examples/axum-example/src/routes/mod.rs | 27 +++- 3 files changed, 286 insertions(+), 27 deletions(-) diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 4c28ae19..8f3e0e3f 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,22 @@ 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); + } + + // Otherwise, treat as single parameter + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Query, description: None, @@ -106,7 +123,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } } "Header" => { @@ -115,7 +132,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 +143,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } } "Json" => { @@ -140,7 +157,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 +168,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 +184,7 @@ pub fn parse_function_parameter( struct_definitions, )), example: None, - }); + }]); } None @@ -175,6 +192,153 @@ 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 +} + +/// 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 +1340,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 9413ced5..8cc5f127 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -453,18 +453,57 @@ "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 + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } } } } + } + } + }, + "/no-schema-query": { + "get": { + "operationId": "mod_file_with_no_schema_query", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "object" + } + } ], "responses": { "200": { @@ -566,11 +605,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 +640,19 @@ "operationId": "mod_file_with_test_struct", "parameters": [ { - "name": "query", + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", "in": "query", "required": true, "schema": { - "$ref": "#/components/schemas/TestStruct" + "type": "integer" } } ], @@ -1176,6 +1231,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 ca274307..01384ede 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -27,10 +27,31 @@ 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" } From 3ffbe3765d43d4a62dbf7ce09c07f94d57be7625 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 19:42:51 +0900 Subject: [PATCH 2/4] Rm unknown query --- crates/vespera_macro/src/parser.rs | 95 ++++++++++++++++++++----- examples/axum-example/openapi.json | 10 --- examples/axum-example/src/routes/mod.rs | 1 + 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 8f3e0e3f..0fadbf6f 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -101,7 +101,7 @@ pub fn parse_function_parameter( 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, @@ -110,7 +110,13 @@ pub fn parse_function_parameter( ) { 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(), @@ -205,6 +211,55 @@ fn is_map_type(ty: &Type) -> bool { 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( @@ -218,25 +273,25 @@ fn parse_query_struct_to_parameters( 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 @@ -244,7 +299,7 @@ fn parse_query_struct_to_parameters( .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 @@ -252,9 +307,9 @@ fn parse_query_struct_to_parameters( // 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, @@ -266,7 +321,7 @@ fn parse_query_struct_to_parameters( .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( @@ -274,14 +329,18 @@ fn parse_query_struct_to_parameters( 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(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) { + 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, @@ -293,7 +352,7 @@ fn parse_query_struct_to_parameters( } } } - + // If it's Option, make it nullable let final_schema = if is_optional { if let SchemaRef::Inline(mut schema) = field_schema { @@ -316,9 +375,9 @@ fn parse_query_struct_to_parameters( SchemaRef::Inline(schema) => SchemaRef::Inline(schema), } }; - + let required = !is_optional; - + parameters.push(Parameter { name: field_name, r#in: ParameterLocation::Query, @@ -329,7 +388,7 @@ fn parse_query_struct_to_parameters( }); } } - + if !parameters.is_empty() { return Some(parameters); } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 8cc5f127..513bf97a 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -495,16 +495,6 @@ "/no-schema-query": { "get": { "operationId": "mod_file_with_no_schema_query", - "parameters": [ - { - "name": "query", - "in": "query", - "required": true, - "schema": { - "type": "object" - } - } - ], "responses": { "200": { "description": "Successful response", diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index 01384ede..cb356185 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -47,6 +47,7 @@ pub struct NoSchemaQuery { 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); From 18348b4e5b5deedb9a4b35bcd2bc50eb5207bb7d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 19:43:03 +0900 Subject: [PATCH 3/4] Rm unknown query --- .changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json diff --git a/.changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json b/.changepacks/changepack_log_Zkq7QYnKabNNqAXICxI0G.json new file mode 100644 index 00000000..aeb50bd0 --- /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 From f150b22b5d4ada886d52a015f15059227f11f13a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Dec 2025 19:46:45 +0900 Subject: [PATCH 4/4] Update snapshot --- .../snapshots/integration_test__openapi.snap | 86 ++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index be71a12a..3a8be192 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": {