From 863b944146ee8626a6c377888c8f06c755175b83 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 13:54:42 +0900 Subject: [PATCH 01/30] Add coverage --- Cargo.lock | 6 +++--- crates/vespera_core/src/schema.rs | 16 ---------------- crates/vespera_macro/src/route/utils.rs | 7 ++++--- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c729cb..57fd0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1501,7 +1501,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.12" +version = "0.1.13" dependencies = [ "axum", "vespera_core", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.12" +version = "0.1.13" dependencies = [ "rstest", "serde", @@ -1519,7 +1519,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "proc-macro2", diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 47ba6cf..236399e 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -13,22 +13,6 @@ 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 { diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index fd4449a..75db78a 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -39,10 +39,11 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { syn::Meta::List(meta_list) => { // Try to parse as RouteArgs if let Ok(route_args) = meta_list.parse_args::() { - let method = route_args.method.as_ref(); - let method = method + let method = route_args + .method + .as_ref() .map(syn::Ident::to_string) - .unwrap_or("get".to_string()); + .unwrap_or_else(|| "get".to_string()); let path = route_args.path.as_ref().map(syn::LitStr::value); // Parse error_status array if present From 9ca39ac2270249a4f2046f218a003e15a50db41a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 14:43:06 +0900 Subject: [PATCH 02/30] Add fmt --- .github/workflows/CI.yml | 13 ++++++++++- crates/vespera_macro/src/route/utils.rs | 31 ++++++++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fad29bb..c5f3201 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -36,7 +36,18 @@ jobs: - name: Build run: cargo check - name: Test - run: cargo tarpaulin --out Lcov + run: | + # rust coverage issue + echo 'max_width = 100000' > .rustfmt.toml + echo 'tab_spaces = 4' >> .rustfmt.toml + echo 'newline_style = "Unix"' >> .rustfmt.toml + echo 'fn_call_width = 100000' >> .rustfmt.toml + echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml + echo 'chain_width = 100000' >> .rustfmt.toml + echo 'merge_derives = true' >> .rustfmt.toml + echo 'use_small_heuristics = "Default"' >> .rustfmt.toml + cargo fmt + cargo tarpaulin --out Lcov - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 75db78a..35c2432 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -47,9 +47,9 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { let path = route_args.path.as_ref().map(syn::LitStr::value); // Parse error_status array if present - let error_status = route_args.error_status.and_then(|array| { + let error_status = route_args.error_status.as_ref().and_then(|array| { let mut status_codes = Vec::new(); - for elem in array.elems { + for elem in &array.elems { if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. @@ -81,15 +81,19 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { }) = &meta_nv.value { let method_str = lit_str.value().to_lowercase(); - match method_str.as_str() { - "get" | "post" | "put" | "patch" | "delete" | "head" | "options" => { - return Some(RouteInfo { - method: method_str, - path: None, - error_status: None, - }); - } - _ => {} + if method_str == "get" + || method_str == "post" + || method_str == "put" + || method_str == "patch" + || method_str == "delete" + || method_str == "head" + || method_str == "options" + { + return Some(RouteInfo { + method: method_str, + path: None, + error_status: None, + }); } } } @@ -222,6 +226,11 @@ mod tests { #[case("#[derive(Debug)] #[route(get, path = \"/api\")] #[test] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] // Multiple route attributes - first one wins #[case("#[route(get, path = \"/first\")] #[route(post, path = \"/second\")] fn test() {}", Some(("get".to_string(), Some("/first".to_string()), None)))] + // Explicit tests for method.as_ref() and path.as_ref().map() coverage + #[case("#[route(path = \"/test\")] fn test() {}", Some(("get".to_string(), Some("/test".to_string()), None)))] // method None, path Some + #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] // method None, path None + #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] // method Some, path None + #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] // method Some, path Some fn test_extract_route_info( #[case] code: &str, #[case] expected: Option<(String, Option, Option>)>, From baa79aa049a872ba09341c4303dfa3e7200ea397 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 15:36:45 +0900 Subject: [PATCH 03/30] Add openapi gen test --- crates/vespera_macro/src/openapi_generator.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 84fed24..4159072 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -253,6 +253,93 @@ pub fn get_users() -> String { assert!(schemas.contains_key("User")); } + #[test] + fn test_generate_openapi_with_enum() { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Status".to_string(), + definition: "enum Status { Active, Inactive, Pending }".to_string(), + }); + + let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Status")); + } + + #[test] + fn test_generate_openapi_with_enum_with_data() { + // Test enum with data (tuple and struct variants) to ensure full coverage + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Message".to_string(), + definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), + }); + + let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Message")); + } + + #[test] + fn test_generate_openapi_with_enum_and_route() { + // Test enum used in route to ensure enum parsing is called in route context + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_content = r#" +pub fn get_status() -> Status { + Status::Active +} +"#; + let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Status".to_string(), + definition: "enum Status { Active, Inactive }".to_string(), + }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/status".to_string(), + function_name: "get_status".to_string(), + module_path: "test::status_route".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_status() -> Status".to_string(), + error_status: None, + }); + + let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + + // Check enum schema + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Status")); + + // Check route + assert!(doc.paths.contains_key("/status")); + } + + #[test] + #[should_panic(expected = "expected `struct`")] + fn test_generate_openapi_with_fallback_item() { + // Test fallback case for non-struct, non-enum items (lines 46-48) + // Use a const item which will be parsed as syn::Item::Const first + // This triggers the fallback case (_ branch) which tries to parse as struct + // The fallback will fail to parse const as struct, causing a panic + // This test verifies that the fallback path (46-48) is executed + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Config".to_string(), + // This will be parsed as syn::Item::Const, triggering the fallback case + definition: "const CONFIG: i32 = 42;".to_string(), + }); + + // This should panic when fallback tries to parse const as struct + let _doc = generate_openapi_doc_with_metadata(None, None, &metadata); + } + #[test] fn test_generate_openapi_with_route_and_struct() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); From 92db0b2b0898d3f6c6fb3bd219812ac7f45a99fc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 15:51:09 +0900 Subject: [PATCH 04/30] Fix conditional --- crates/vespera_macro/src/args.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index b9b58cb..57d5d2e 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -36,15 +36,15 @@ impl syn::parse::Parse for RouteArgs { return Err(lookahead.error()); } } - } else { - return Err(lookahead.error()); - } - // Check if there's a comma - if input.peek(syn::Token![,]) { - input.parse::()?; + // Check if there's a comma + if input.peek(syn::Token![,]) { + input.parse::()?; + } else { + break; + } } else { - break; + return Err(lookahead.error()); } } From a954e43fa71ad601fc47fe9db29c54bd2248e4fe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 17:32:28 +0900 Subject: [PATCH 05/30] Fix body logic --- crates/vespera_macro/src/parser.rs | 202 ++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 17 deletions(-) diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index d498d79..5d4ab27 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -209,22 +209,7 @@ pub fn parse_function_parameter( }]); } - // Check if it's a primitive type (direct parameter) - if is_primitive_type(ty.as_ref()) { - return Some(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Query, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - + // Bare primitive without extractor is ignored (cannot infer location) None } } @@ -713,7 +698,7 @@ pub fn parse_enum_to_schema( prefix_items: Some(tuple_item_schemas), min_items: Some(tuple_len), max_items: Some(tuple_len), - items: None, // prefixItems와 items는 함께 사용하지 않음 + items: None, // Do not use prefixItems and items together ..Schema::new(SchemaType::Array) }; @@ -1590,6 +1575,49 @@ pub fn build_operation_from_function( } } + // Fallback: if last arg is String/&str and no body yet, treat as text/plain body + if request_body.is_none() { + if let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { + let is_string = match ty.as_ref() { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false), + Type::Reference(type_ref) => { + if let Type::Path(p) = type_ref.elem.as_ref() { + p.path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false) + } else { + false + } + } + _ => false, + }; + + if is_string { + let mut content = BTreeMap::new(); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + examples: None, + }, + ); + request_body = Some(RequestBody { + description: None, + content, + required: Some(true), + }); + } + } + } + // Parse return type - may return multiple responses (for Result types) let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); @@ -1937,6 +1965,146 @@ mod tests { } } + #[rstest] + #[case( + "fn test(params: Path<(String, i32)>) {}", + vec!["user_id".to_string(), "count".to_string()], + vec![vec![ParameterLocation::Path, ParameterLocation::Path]] + )] + #[case( + "fn test(Query(params): Query>) {}", + vec![], + vec![vec![]] // Query> is ignored + )] + #[case( + "fn test(Header(token): Header, count: i32) {}", + vec![], + vec![ + vec![ParameterLocation::Header], // first arg (Header) + vec![], // second arg (primitive, ignored) + ] + )] + fn test_parse_function_parameter_cases( + #[case] func_src: &str, + #[case] path_params: Vec, + #[case] expected_locations: Vec>, + ) { + let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + for (idx, arg) in func.sig.inputs.iter().enumerate() { + let result = parse_function_parameter( + arg, + &path_params, + &HashMap::new(), + &HashMap::new(), + ); + let expected = expected_locations + .get(idx) + .unwrap_or_else(|| expected_locations.last().unwrap()); + + if expected.is_empty() { + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}", + idx, func_src + ); + continue; + } + + let params = result.as_ref().expect("Expected Some parameters"); + let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); + assert_eq!( + got_locs, *expected, + "Location mismatch at arg index {idx}, func: {func_src}" + ); + } + } + + #[rstest] + #[case("fn test(Json(payload): Json) {}", true)] + #[case("fn test(not_json: String) {}", false)] + fn test_parse_request_body_json_cases(#[case] func_src: &str, #[case] has_body: bool) { + let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + let arg = func.sig.inputs.first().unwrap(); + let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); + assert_eq!(body.is_some(), has_body); + } + + #[rstest] + #[case("HashMap", Some(SchemaType::Object), true)] + #[case("Option", Some(SchemaType::String), false)] // nullable check + fn test_parse_type_to_schema_ref_cases( + #[case] ty_src: &str, + #[case] expected_type: Option, + #[case] expect_additional_props: bool, + ) { + let ty: Type = syn::parse_str(ty_src).unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = schema_ref { + assert_eq!(schema.schema_type, expected_type); + if expect_additional_props { + assert!(schema.additional_properties.is_some()); + } + if ty_src.starts_with("Option") { + assert_eq!(schema.nullable, Some(true)); + } + } else { + panic!("Expected inline schema for {}", ty_src); + } + } + + #[test] + fn test_parse_type_to_schema_ref_generic_substitution() { + // Ensure generic struct Wrapper { value: T } is substituted to concrete type + let mut known_schemas = HashMap::new(); + known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string()); + + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "Wrapper".to_string(), + "struct Wrapper { value: T }".to_string(), + ); + + let ty: Type = syn::parse_str("Wrapper").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions); + + if let SchemaRef::Inline(schema) = schema_ref { + let props = schema.properties.as_ref().unwrap(); + let value = props.get("value").unwrap(); + if let SchemaRef::Inline(inner) = value { + assert_eq!(inner.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema for value"); + } + } else { + panic!("Expected inline schema for generic substitution"); + } + } + + #[test] + fn test_build_operation_string_body_fallback() { + let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); + let op = build_operation_from_function( + &sig, + "/upload", + &HashMap::new(), + &HashMap::new(), + None, + ); + + // Ensure body is set as text/plain + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + let media = body.content.get("text/plain").unwrap(); + if let SchemaRef::Inline(schema) = media.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("inline string schema expected"); + } + + // No parameters should be present + assert!(op.parameters.is_none()); + } + #[test] fn test_parse_return_type_with_known_schema() { let mut known_schemas = HashMap::new(); From 5dfe59a129cfdf0a0151a9a601cbfbd7f2cd4ebc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 20:41:16 +0900 Subject: [PATCH 06/30] Add testcase --- crates/vespera_macro/src/parser.rs | 433 ++++++++--------------------- 1 file changed, 114 insertions(+), 319 deletions(-) diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 5d4ab27..3f28164 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -42,11 +42,10 @@ pub fn parse_function_parameter( Pat::Ident(ident) => ident.ident.to_string(), Pat::TupleStruct(tuple_struct) => { // Handle Path(id) pattern - if tuple_struct.elems.len() == 1 { - match &tuple_struct.elems[0] { - Pat::Ident(ident) => ident.ident.to_string(), - _ => return None, - } + if tuple_struct.elems.len() == 1 + && let Pat::Ident(ident) = &tuple_struct.elems[0] + { + ident.ident.to_string() } else { return None; } @@ -1223,28 +1222,23 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { 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 Type::Path(type_path) = &tuple.elems[0]&& !&type_path.path.segments.is_empty() { 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())); - } + 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 } @@ -1548,29 +1542,21 @@ pub fn build_operation_from_function( request_body = Some(body); } else { // Skip Path extractor - we already handled path parameters above - let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input { - if let Type::Path(type_path) = ty.as_ref() { - let path_segments = &type_path.path; - if !path_segments.segments.is_empty() { - let segment = path_segments.segments.last().unwrap(); - segment.ident == "Path" - } else { - false - } - } else { - false - } + let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input + && let Type::Path(type_path) = ty.as_ref() + && !&type_path.path.segments.is_empty() + { + let segment = &type_path.path.segments.last().unwrap(); + segment.ident == "Path" } else { false }; - if !is_path_extractor { - // Process non-Path parameters - if let Some(params) = + if !is_path_extractor + && let Some(params) = parse_function_parameter(input, &path_params, known_schemas, struct_definitions) - { - parameters.extend(params); - } + { + parameters.extend(params); } } } @@ -1971,6 +1957,11 @@ mod tests { vec!["user_id".to_string(), "count".to_string()], vec![vec![ParameterLocation::Path, ParameterLocation::Path]] )] + #[case( + "fn show(Path(id): Path) {}", + vec!["item_id".to_string()], // path string name differs from pattern + vec![vec![ParameterLocation::Path]] // expect path param captured + )] #[case( "fn test(Query(params): Query>) {}", vec![], @@ -1991,12 +1982,8 @@ mod tests { ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = parse_function_parameter( - arg, - &path_params, - &HashMap::new(), - &HashMap::new(), - ); + let result = + parse_function_parameter(arg, &path_params, &HashMap::new(), &HashMap::new()); let expected = expected_locations .get(idx) .unwrap_or_else(|| expected_locations.last().unwrap()); @@ -2005,7 +1992,8 @@ mod tests { assert!( result.is_none(), "Expected None at arg index {}, func: {}", - idx, func_src + idx, + func_src ); continue; } @@ -2083,13 +2071,8 @@ mod tests { #[test] fn test_build_operation_string_body_fallback() { let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); - let op = build_operation_from_function( - &sig, - "/upload", - &HashMap::new(), - &HashMap::new(), - None, - ); + let op = + build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); // Ensure body is set as text/plain let body = op.request_body.as_ref().expect("request body expected"); @@ -2105,289 +2088,101 @@ mod tests { assert!(op.parameters.is_none()); } - #[test] - 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, &struct_definitions); - - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert!(response.content.is_some()); - - let content = response.content.as_ref().unwrap(); - let media_type = content.get("application/json").unwrap(); - - // Should be a reference to the known schema - if let SchemaRef::Ref(ref_ref) = media_type.schema.as_ref().unwrap() { - assert_eq!(ref_ref.ref_path, "#/components/schemas/User"); - } else { - panic!("Expected schema reference for known type"); - } - } - { - let return_type_str = "-> Json"; - let full_signature = format!("fn test() {}", return_type_str); - let parsed: syn::Signature = - 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); - println!("responses: {:?}", responses); - - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert!(response.content.is_some()); - - let content = response.content.as_ref().unwrap(); - let media_type = content.get("application/json").unwrap(); - - // Should be a reference to the known schema - if let SchemaRef::Ref(ref_ref) = media_type.schema.as_ref().unwrap() { - assert_eq!(ref_ref.ref_path, "#/components/schemas/User"); - } else { - panic!("Expected schema reference for Json"); - } - } - } - - #[test] - fn test_parse_return_type_result_with_known_schema() { + #[rstest] + #[case("-> User", Some("User"), None, None, false, None)] // known schema ref + #[case("-> Json", Some("User"), None, None, false, None)] // unwrap Json, known ref + #[case("-> Result", Some("User"), None, Some("Error"), false, None)] // Ok/Err refs + #[case( + "-> Result<(HeaderMap, String), String>", + None, + Some(SchemaType::String), + None, + true, + Some(SchemaType::String) + )] // HeaderMap sets headers, Ok body is String + #[case( + "-> Result<(StatusCode, HeaderMap, String), String>", + None, + Some(SchemaType::String), + None, + true, + Some(SchemaType::String) + )] + #[case( + "-> Result<(StatusCode, HeaderMap, u32, String), String>", + None, + Some(SchemaType::String), + None, + true, + Some(SchemaType::String) + )] + fn test_parse_return_type_additional_cases( + #[case] return_type_str: &str, + #[case] expect_ok_ref: Option<&str>, + #[case] expect_ok_type: Option, + #[case] expect_err_ref: Option<&str>, + #[case] expect_ok_headers: bool, + #[case] expect_err_type: Option, + ) { 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, &struct_definitions); - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - // Check Ok response has User schema reference - let ok_response = responses.get("200").unwrap(); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Ref(ref_ref) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(ref_ref.ref_path, "#/components/schemas/User"); - } else { - panic!("Expected schema reference for User"); - } - - // Check Err response has Error schema reference - let err_response = responses.get("400").unwrap(); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Ref(ref_ref) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(ref_ref.ref_path, "#/components/schemas/Error"); - } else { - panic!("Expected schema reference for Error"); - } - } - - #[test] - fn test_parse_return_type_with_header_map_tuple() { - let known_schemas = HashMap::new(); - let struct_definitions = HashMap::new(); - - let parsed: syn::Signature = - syn::parse_str("fn test() -> Result<(HeaderMap, String), String>") - .expect("Failed to parse return type"); - - let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); - - let ok_response = responses.get("200").expect("Ok response missing"); - let ok_content = ok_response - .content - .as_ref() - .expect("Ok content missing") - .get("application/json") - .expect("application/json missing"); - - if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } - - assert!( - ok_response.headers.is_some(), - "HeaderMap should set headers" - ); - } - - #[test] - fn test_parse_return_type_with_status_and_header_map_tuple() { - let known_schemas = HashMap::new(); - let struct_definitions = HashMap::new(); - - let parsed: syn::Signature = - syn::parse_str("fn test() -> Result<(StatusCode, HeaderMap, String), String>") - .expect("Failed to parse return type"); - - let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); - + // Ok response let ok_response = responses.get("200").expect("Ok response missing"); - let ok_content = ok_response - .content - .as_ref() - .expect("Ok content missing") - .get("application/json") - .expect("application/json missing"); - - if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); + if expect_ok_headers { + assert!(ok_response.headers.is_some(), "Expected headers set for Ok"); } else { - panic!("Expected inline String schema for Ok type"); + assert!(ok_response.headers.is_none(), "Headers should be None for Ok"); } - - assert!( - ok_response.headers.is_some(), - "HeaderMap should set headers" - ); - } - - #[test] - fn test_parse_return_type_with_mixed_tuple_uses_last_as_body() { - let known_schemas = HashMap::new(); - let struct_definitions = HashMap::new(); - - // Additional tuple elements before the payload should be ignored; last element is body - let parsed: syn::Signature = - syn::parse_str("fn test() -> Result<(StatusCode, HeaderMap, u32, String), String>") - .expect("Failed to parse return type"); - - let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); - - let ok_response = responses.get("200").expect("Ok response missing"); - let ok_content = ok_response - .content - .as_ref() - .expect("Ok content missing") + let ok_content = ok_response.content.as_ref().expect("Ok content missing"); + let ok_media_type = ok_content .get("application/json") - .expect("application/json missing"); - - if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } - - assert!( - ok_response.headers.is_some(), - "HeaderMap should set headers" - ); - } - - #[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), - ("-> i16", SchemaType::Integer), - ("-> i32", SchemaType::Integer), - ("-> i64", SchemaType::Integer), - ("-> u8", SchemaType::Integer), - ("-> u16", SchemaType::Integer), - ("-> u32", SchemaType::Integer), - ("-> u64", SchemaType::Integer), - ("-> f32", SchemaType::Number), - ("-> f64", SchemaType::Number), - ("-> bool", SchemaType::Boolean), - ("-> String", SchemaType::String), - ]; - - for (return_type_str, expected_schema_type) in test_cases { - let full_signature = format!("fn test() {}", return_type_str); - 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); - - assert_eq!(responses.len(), 1); - let response = responses.get("200").unwrap(); - let content = response.content.as_ref().unwrap(); - let media_type = content.get("application/json").unwrap(); - - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(expected_schema_type)); + .expect("Ok application/json missing"); + if let Some(expected_ref) = expect_ok_ref { + if let SchemaRef::Ref(r) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(r.ref_path, format!("#/components/schemas/{}", expected_ref)); } else { - panic!( - "Expected inline schema for primitive type: {}", - return_type_str - ); + panic!("Expected schema ref for Ok"); + } + } else if let Some(expected_type) = expect_ok_type { + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(expected_type)); + } else { + panic!("Expected inline schema for Ok"); } } - } - - #[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, &struct_definitions); - - assert_eq!(responses.len(), 1); - let response = responses.get("200").unwrap(); - let content = response.content.as_ref().unwrap(); - let media_type = content.get("application/json").unwrap(); - - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert!(schema.items.is_some()); - } else { - panic!("Expected inline array schema"); - } - } - - #[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, &struct_definitions); - - assert_eq!(responses.len(), 1); - let response = responses.get("200").unwrap(); - let content = response.content.as_ref().unwrap(); - let media_type = content.get("application/json").unwrap(); - - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.nullable, Some(true)); - // Check that inner type is String - if let Some(items) = &schema.items { - if let SchemaRef::Inline(inner_schema) = items.as_ref() { - assert_eq!(inner_schema.schema_type, Some(SchemaType::String)); - } + // Err response (if present) + if let Some(expected_err_ref) = expect_err_ref { + let err_response = responses.get("400").expect("Err response missing"); + let err_content = err_response.content.as_ref().expect("Err content missing"); + let err_media_type = err_content + .get("application/json") + .expect("Err application/json missing"); + if let SchemaRef::Ref(r) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(r.ref_path, format!("#/components/schemas/{}", expected_err_ref)); + } else { + panic!("Expected schema ref for Err"); + } + } else if let Some(expected_err_type) = expect_err_type { + let err_response = responses.get("400").expect("Err response missing"); + let err_content = err_response.content.as_ref().expect("Err content missing"); + let err_media_type = err_content + .get("application/json") + .expect("Err application/json missing"); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(expected_err_type)); + } else { + panic!("Expected inline schema for Err"); } - } else { - panic!("Expected inline nullable schema"); } } From 53db5669e73a5d9a3afa10f883b81aeb10a44092 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 00:45:44 +0900 Subject: [PATCH 07/30] Split parse --- Cargo.lock | 293 ++- crates/vespera/Cargo.toml | 4 + crates/vespera/src/lib.rs | 4 + crates/vespera_macro/Cargo.toml | 4 +- crates/vespera_macro/src/parser.rs | 2258 ----------------- crates/vespera_macro/src/parser/mod.rs | 10 + crates/vespera_macro/src/parser/operation.rs | 291 +++ crates/vespera_macro/src/parser/parameters.rs | 517 ++++ crates/vespera_macro/src/parser/path.rs | 33 + .../vespera_macro/src/parser/request_body.rs | 132 + crates/vespera_macro/src/parser/response.rs | 548 ++++ crates/vespera_macro/src/parser/schema.rs | 825 ++++++ ...sts__parse_function_parameter_cases-2.snap | 61 + ...sts__parse_function_parameter_cases-3.snap | 61 + ...sts__parse_function_parameter_cases-4.snap | 61 + ...sts__parse_function_parameter_cases-5.snap | 61 + ...sts__parse_function_parameter_cases-6.snap | 61 + ...sts__parse_function_parameter_cases-7.snap | 61 + ...tests__parse_function_parameter_cases.snap | 116 + ...dy__tests__parse_request_body_cases-2.snap | 64 + ...dy__tests__parse_request_body_cases-3.snap | 64 + ...dy__tests__parse_request_body_cases-4.snap | 5 + ...body__tests__parse_request_body_cases.snap | 64 + examples/axum-example/Cargo.toml | 2 +- examples/axum-example/openapi.json | 60 + examples/axum-example/src/routes/mod.rs | 1 + .../axum-example/src/routes/typed_header.rs | 20 + .../snapshots/integration_test__openapi.snap | 60 + openapi.json | 60 + 29 files changed, 3519 insertions(+), 2282 deletions(-) delete mode 100644 crates/vespera_macro/src/parser.rs create mode 100644 crates/vespera_macro/src/parser/mod.rs create mode 100644 crates/vespera_macro/src/parser/operation.rs create mode 100644 crates/vespera_macro/src/parser/parameters.rs create mode 100644 crates/vespera_macro/src/parser/path.rs create mode 100644 crates/vespera_macro/src/parser/request_body.rs create mode 100644 crates/vespera_macro/src/parser/response.rs create mode 100644 crates/vespera_macro/src/parser/schema.rs create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap create mode 100644 examples/axum-example/src/routes/typed_header.rs diff --git a/Cargo.lock b/Cargo.lock index 57fd0b4..c576b30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,11 +102,40 @@ dependencies = [ "vespera", ] +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "form_urlencoded", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-test" -version = "18.3.0" +version = "18.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" +checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2" dependencies = [ "anyhow", "axum", @@ -131,12 +160,27 @@ dependencies = [ "url", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -157,9 +201,9 @@ checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" [[package]] name = "cc" -version = "1.2.47" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -202,6 +246,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] @@ -212,6 +257,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.5" @@ -227,6 +291,16 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -253,6 +327,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -282,14 +365,15 @@ dependencies = [ [[package]] name = "expect-json" -version = "1.5.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9" dependencies = [ "chrono", "email_address", "expect-json-macros", "num", + "regex", "serde", "serde_json", "thiserror", @@ -329,6 +413,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -336,6 +435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -344,6 +444,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -361,6 +472,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -379,9 +496,11 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -389,6 +508,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -413,6 +542,30 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "http" version = "1.4.0" @@ -639,9 +792,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.44.1" +version = "1.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8732d3774162a0851e3f2b150eb98f31a9885dd75985099421d393385a01dfd" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" dependencies = [ "console", "once_cell", @@ -665,9 +818,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -675,9 +828,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -702,9 +855,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchit" @@ -735,6 +888,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "num" version = "0.4.3" @@ -1088,12 +1258,27 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -1130,6 +1315,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -1166,6 +1364,42 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1209,6 +1443,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1435,6 +1675,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "typetag" version = "0.2.21" @@ -1504,6 +1750,7 @@ name = "vespera" version = "0.1.13" dependencies = [ "axum", + "axum-extra", "vespera_core", "vespera_macro", ] @@ -1522,11 +1769,13 @@ name = "vespera_macro" version = "0.1.13" dependencies = [ "anyhow", + "insta", "proc-macro2", "quote", "rstest", "serde", "serde_json", + "serial_test", "syn", "tempfile", "vespera_core", @@ -1558,9 +1807,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -1571,9 +1820,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1581,9 +1830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -1594,9 +1843,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 9a27077..6d38f0a 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -7,7 +7,11 @@ license = "Apache-2.0" repository = "https://github.com/dev-five-git/vespera" readme = "../../README.md" +[features] +default = ["dep:axum-extra", "axum-extra/typed-header", "axum-extra/form", "axum-extra/query", "axum-extra/multipart", "axum-extra/cookie"] + [dependencies] vespera_core = { workspace = true } vespera_macro = { workspace = true } axum = "0.8" +axum-extra = { version = "0.12", optional = true } diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index cee8f57..cbbeb28 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -23,3 +23,7 @@ pub use vespera_macro::{Schema, route, vespera}; pub mod axum { pub use axum::*; } + +pub mod axum_extra { + pub use axum_extra::*; +} diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index 0a9123a..c8f25fa 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -21,4 +21,6 @@ anyhow = "1.0" [dev-dependencies] rstest = "0.26" -tempfile = "3" \ No newline at end of file +insta = "1.44" +tempfile = "3" +serial_test = "3.2" diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs deleted file mode 100644 index 3f28164..0000000 --- a/crates/vespera_macro/src/parser.rs +++ /dev/null @@ -1,2258 +0,0 @@ -//! Parser module for analyzing function signatures and converting to OpenAPI structures - -use std::collections::{BTreeMap, HashMap}; -use syn::{Fields, FnArg, Pat, PatType, ReturnType, Type}; -use vespera_core::{ - route::{Header, MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response}, - schema::{Reference, Schema, SchemaRef, SchemaType}, -}; - -/// Extract path parameters from a path string -pub fn extract_path_parameters(path: &str) -> Vec { - let mut params = Vec::new(); - let segments: Vec<&str> = path.split('/').collect(); - - for segment in segments { - if segment.starts_with('{') && segment.ends_with('}') { - let param = segment.trim_start_matches('{').trim_end_matches('}'); - params.push(param.to_string()); - } else if segment.starts_with(':') { - let param = segment.trim_start_matches(':'); - params.push(param.to_string()); - } - } - - params -} - -/// 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> { - match arg { - FnArg::Receiver(_) => None, - FnArg::Typed(PatType { pat, ty, .. }) => { - // Extract parameter name from pattern - let param_name = match pat.as_ref() { - Pat::Ident(ident) => ident.ident.to_string(), - Pat::TupleStruct(tuple_struct) => { - // Handle Path(id) pattern - if tuple_struct.elems.len() == 1 - && let Pat::Ident(ident) = &tuple_struct.elems[0] - { - ident.ident.to_string() - } else { - return None; - } - } - _ => return None, - }; - - // 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() { - // 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() { - "Path" => { - // Path extractor - use path parameter name from route if available - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Check if inner type is a tuple (e.g., Path<(String, String, String)>) - if let Type::Tuple(tuple) = inner_ty { - // For tuple types, extract parameters from path string - let mut parameters = Vec::new(); - let tuple_elems = &tuple.elems; - - // Match tuple elements with path parameters - for (idx, elem_ty) in tuple_elems.iter().enumerate() { - if let Some(param_name) = path_params.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some( - parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - ), - ), - example: None, - }); - } - } - - if !parameters.is_empty() { - return Some(parameters); - } - } else { - // Single path parameter - // If there's exactly one path parameter, use its name - let name = if path_params.len() == 1 { - path_params[0].clone() - } else { - // Otherwise use the parameter name from the pattern - param_name - }; - return Some(vec![Parameter { - name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - } - "Query" => { - // Query extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // 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, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "Header" => { - // Header extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - return Some(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "Json" => { - // Json extractor - this will be handled as RequestBody - return None; - } - _ => {} - } - } - } - - // Check if it's a path parameter (by name match) - for non-extractor cases - if path_params.contains(¶m_name) { - return Some(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - - // Bare primitive without extractor is ignored (cannot infer location) - None - } - } -} - -/// 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) - - // Check if it's in struct_definitions or known_schemas - if struct_definitions.contains_key(&ident_str) || known_schemas.contains_key(&ident_str) { - 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) - - // Check if it's a known struct - if let Some(struct_def) = struct_definitions.get(&ident_str) - && 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/") - && let Some(struct_def) = struct_definitions.get(type_name) - && 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 { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.len() == 1 { - let ident = path.segments[0].ident.to_string(); - matches!( - ident.as_str(), - "i8" | "i16" - | "i32" - | "i64" - | "u8" - | "u16" - | "u32" - | "u64" - | "f32" - | "f64" - | "bool" - | "String" - | "str" - ) - } else { - false - } - } - _ => false, - } -} - -/// Extract rename_all attribute from struct attributes -fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - // Parse the attribute tokens manually - // Format: #[serde(rename_all = "camelCase")] - let tokens = attr.meta.require_list().ok()?; - let token_str = tokens.tokens.to_string(); - - // Look for rename_all = "..." pattern - if let Some(start) = token_str.find("rename_all") { - let remaining = &token_str[start + "rename_all".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = &remaining[equals_pos + 1..].trim(); - // Extract string value (remove quotes) - if value_part.starts_with('"') && value_part.ends_with('"') { - let value = &value_part[1..value_part.len() - 1]; - return Some(value.to_string()); - } - } - } - } - } - None -} - -/// Extract rename attribute from field attributes -/// Handles #[serde(rename = "newName")] -fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - // Try to parse as Meta::List first - if let syn::Meta::List(meta_list) = &attr.meta { - let tokens = meta_list.tokens.to_string(); - - // Look for rename = "..." pattern - if let Some(start) = tokens.find("rename") { - let remaining = &tokens[start + "rename".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = &remaining[equals_pos + 1..].trim(); - // Extract string value (remove quotes) - if value_part.starts_with('"') && value_part.ends_with('"') { - let value = &value_part[1..value_part.len() - 1]; - return Some(value.to_string()); - } - } - } - } - } - } - None -} - -/// Convert field name according to rename_all rule -fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { - // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" - match rename_all { - Some("camelCase") => { - // Convert snake_case to camelCase - let mut result = String::new(); - let mut capitalize_next = false; - for ch in field_name.chars() { - if ch == '_' { - capitalize_next = true; - } else if capitalize_next { - result.push(ch.to_uppercase().next().unwrap_or(ch)); - capitalize_next = false; - } else { - result.push(ch); - } - } - result - } - Some("snake_case") => { - // Convert camelCase to snake_case - let mut result = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 { - result.push('_'); - } - result.push(ch.to_lowercase().next().unwrap_or(ch)); - } - result - } - Some("kebab-case") => { - // Convert snake_case to kebab-case - field_name.replace('_', "-") - } - Some("PascalCase") => { - // Convert snake_case to PascalCase - let mut result = String::new(); - let mut capitalize_next = true; - for ch in field_name.chars() { - if ch == '_' { - capitalize_next = true; - } else if capitalize_next { - result.push(ch.to_uppercase().next().unwrap_or(ch)); - capitalize_next = false; - } else { - result.push(ch); - } - } - result - } - Some("lowercase") => { - // Convert to lowercase - field_name.to_lowercase() - } - Some("UPPERCASE") => { - // Convert to UPPERCASE - field_name.to_uppercase() - } - Some("SCREAMING_SNAKE_CASE") => { - // Convert to SCREAMING_SNAKE_CASE - // If already in SCREAMING_SNAKE_CASE format, return as is - if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') - { - return field_name.to_string(); - } - // First convert to snake_case if needed, then uppercase - let mut snake_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { - snake_case.push('_'); - } - if ch != '_' && ch != '-' { - snake_case.push(ch.to_lowercase().next().unwrap_or(ch)); - } else if ch == '_' { - snake_case.push('_'); - } - } - snake_case.to_uppercase() - } - Some("SCREAMING-KEBAB-CASE") => { - // Convert to SCREAMING-KEBAB-CASE - // First convert to kebab-case if needed, then uppercase - let mut kebab_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() - && i > 0 - && !kebab_case.ends_with('-') - && !kebab_case.ends_with('_') - { - kebab_case.push('-'); - } - if ch == '_' { - kebab_case.push('-'); - } else if ch != '-' { - kebab_case.push(ch.to_lowercase().next().unwrap_or(ch)); - } else { - kebab_case.push('-'); - } - } - kebab_case.to_uppercase() - } - _ => field_name.to_string(), - } -} - -/// Parse enum definition to OpenAPI Schema -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); - - // Check if all variants are unit variants - let all_unit = enum_item - .variants - .iter() - .all(|v| matches!(v.fields, syn::Fields::Unit)); - - if all_unit { - // Simple enum with string values - let mut enum_values = Vec::new(); - - for variant in &enum_item.variants { - let variant_name = variant.ident.to_string(); - - // Check for variant-level rename attribute first (takes precedence) - let enum_value = if let Some(renamed) = extract_field_rename(&variant.attrs) { - renamed - } else { - // Apply rename_all transformation if present - rename_field(&variant_name, rename_all.as_deref()) - }; - - enum_values.push(serde_json::Value::String(enum_value)); - } - - Schema { - schema_type: Some(SchemaType::String), - r#enum: if enum_values.is_empty() { - None - } else { - Some(enum_values) - }, - ..Schema::string() - } - } else { - // Enum with data - use oneOf - let mut one_of_schemas = Vec::new(); - - for variant in &enum_item.variants { - let variant_name = variant.ident.to_string(); - - // Check for variant-level rename attribute first (takes precedence) - let variant_key = if let Some(renamed) = extract_field_rename(&variant.attrs) { - renamed - } else { - // Apply rename_all transformation if present - rename_field(&variant_name, rename_all.as_deref()) - }; - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant: {"const": "VariantName"} - Schema { - schema_type: Some(SchemaType::String), - r#enum: Some(vec![serde_json::Value::String(variant_key)]), - ..Schema::string() - } - } - syn::Fields::Unnamed(fields_unnamed) => { - // Tuple variant: {"VariantName": } - // For single field: {"VariantName": } - // For multiple fields: {"VariantName": [, , ...]} - 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, struct_definitions); - - let mut properties = BTreeMap::new(); - properties.insert(variant_key.clone(), inner_schema); - - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } else { - // Multiple fields tuple variant - serialize as array - // serde serializes tuple variants as: {"VariantName": [value1, value2, ...]} - // 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, - ); - tuple_item_schemas.push(field_schema); - } - - let tuple_len = tuple_item_schemas.len(); - - // Create array schema with prefixItems for tuple arrays (OpenAPI 3.1) - let array_schema = Schema { - schema_type: Some(SchemaType::Array), - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, // Do not use prefixItems and items together - ..Schema::new(SchemaType::Array) - }; - - let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(array_schema)), - ); - - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - } - syn::Fields::Named(fields_named) => { - // Struct variant: {"VariantName": {field1: type1, field2: type2, ...}} - let mut variant_properties = BTreeMap::new(); - let mut variant_required = Vec::new(); - let variant_rename_all = extract_rename_all(&variant.attrs); - - 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, - variant_rename_all.as_deref().or(rename_all.as_deref()), - ) - }; - - let field_type = &field.ty; - let schema_ref = - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); - - variant_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 { - variant_required.push(field_name); - } - } - - // Wrap struct variant in an object with the variant name as key - let inner_struct_schema = Schema { - schema_type: Some(SchemaType::Object), - properties: if variant_properties.is_empty() { - None - } else { - Some(variant_properties) - }, - required: if variant_required.is_empty() { - None - } else { - Some(variant_required) - }, - ..Schema::object() - }; - - let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(inner_struct_schema)), - ); - - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, // oneOf doesn't have a single type - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - ..Schema::new(SchemaType::Object) - } - } -} - -/// 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(); - - // 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() - } -} - -/// 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, - 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; - if path.segments.is_empty() { - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - - // Get the last segment as the type name (handles paths like crate::TestStruct) - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Handle generic types - 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() { - 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 { - // Option -> nullable schema - if let SchemaRef::Inline(mut schema) = inner_schema { - schema.nullable = Some(true); - return SchemaRef::Inline(schema); - } - } - } - } - "HashMap" | "BTreeMap" => { - // HashMap or BTreeMap -> object with additionalProperties - // K is typically String, we use V as the value type - if args.args.len() >= 2 - && let ( - 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() - })); - } - } - _ => {} - } - } - - // Handle primitive types - match ident_str.as_str() { - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { - SchemaRef::Inline(Box::new(Schema::integer())) - } - "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), - "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), - "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), - // Standard library types that should not be referenced - // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { - // These are not schema types, return object schema - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - _ => { - // Check if this is a known schema (struct with Schema derive) - // Try both the full path and just the type name - let type_name = if path.segments.len() > 1 { - // For paths like crate::TestStruct, use just the type name - ident_str.clone() - } else { - ident_str.clone() - }; - - if known_schemas.contains_key(&type_name) { - // 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 - // 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 - // This prevents creating invalid references to non-existent schemas - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - } - } - } - Type::Reference(type_ref) => { - // Handle &T, &mut T, etc. - parse_type_to_schema_ref_with_schemas(&type_ref.elem, known_schemas, struct_definitions) - } - _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), - } -} - -/// Analyze function signature and extract RequestBody -pub fn parse_request_body( - arg: &FnArg, - known_schemas: &HashMap, - struct_definitions: &HashMap, -) -> Option { - match arg { - FnArg::Receiver(_) => None, - FnArg::Typed(PatType { ty, .. }) => { - if let Type::Path(type_path) = ty.as_ref() { - let path = &type_path.path; - if path.segments.is_empty() { - return None; - } - - // 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_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - ); - let mut content = BTreeMap::new(); - content.insert( - "application/json".to_string(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); - return Some(RequestBody { - description: None, - required: Some(true), - content, - }); - } - } - None - } - } -} - -/// 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() { - // 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; - } - } - } - ty -} - -/// Extract Ok and Err types from Result or Result, E> -/// Handles both Result and std::result::Result, and unwraps references -fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { - // First unwrap Json if present - let unwrapped = unwrap_json(ty); - - // Handle both Type::Path and Type::Reference (for &Result<...>) - let result_type = match unwrapped { - Type::Path(type_path) => type_path, - Type::Reference(type_ref) => { - // Unwrap reference and check if it's a Result - if let Type::Path(type_path) = type_ref.elem.as_ref() { - type_path - } else { - return None; - } - } - _ => return None, - }; - - let path = &result_type.path; - if path.segments.is_empty() { - return None; - } - - // Check if any segment is "Result" (handles both Result and std::result::Result) - let is_result = path.segments.iter().any(|seg| seg.ident == "Result"); - - if is_result { - // 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())); - } - } - None -} - -/// Check if error type is a tuple (StatusCode, E) or (StatusCode, Json) -/// 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 - && let Type::Path(type_path) = &tuple.elems[0]&& !&type_path.path.segments.is_empty() { - let path = &type_path.path; - 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 -} - -/// Check whether the provided type is a HeaderMap -fn is_header_map_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return false; - } - return path.segments.iter().any(|s| s.ident == "HeaderMap"); - } - false -} - -/// Extract payload type from an Ok tuple and track if headers exist. -/// The last element of the tuple is always treated as the response body. -/// Any presence of HeaderMap in the tuple marks headers as present. -fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { - if let Type::Tuple(tuple) = ok_ty { - let payload_ty = tuple.elems.last().map(|ty| unwrap_json(ty).clone()); - let has_headers = tuple.elems.iter().any(is_header_map_type); - - if let Some(payload_ty) = payload_ty { - let headers = if has_headers { - Some(HashMap::new()) - } else { - None - }; - return (payload_ty, headers); - } - } - - (ok_ty.clone(), None) -} - -/// Analyze return type and convert to Responses map -pub fn parse_return_type( - return_type: &ReturnType, - known_schemas: &HashMap, - struct_definitions: &HashMap, -) -> BTreeMap { - let mut responses = BTreeMap::new(); - - match return_type { - ReturnType::Default => { - // No return type - just 200 with no content - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: None, - content: None, - }, - ); - } - ReturnType::Type(_, ty) => { - // Check if it's a Result - if let Some((ok_ty, err_ty)) = extract_result_types(ty) { - // Handle success response (200) - let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); - let ok_schema = parse_type_to_schema_ref_with_schemas( - &ok_payload_ty, - known_schemas, - struct_definitions, - ); - let mut ok_content = BTreeMap::new(); - ok_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(ok_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: ok_headers, - content: Some(ok_content), - }, - ); - - // Handle error response - // 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_with_schemas( - &error_type, - known_schemas, - struct_definitions, - ); - let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - status_code.to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); - } else { - // 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_with_schemas( - err_ty_unwrapped, - known_schemas, - struct_definitions, - ); - let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "400".to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); - } - } else { - // Not a Result type - regular response - // Unwrap Json if present - let unwrapped_ty = unwrap_json(ty); - 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(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: None, - content: Some(content), - }, - ); - } - } - } - - responses -} - -/// Build Operation from function signature -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); - let mut parameters = Vec::new(); - let mut request_body = None; - let mut path_extractor_type: Option = None; - - // First pass: find Path extractor and extract its type - for input in &sig.inputs { - if let FnArg::Typed(PatType { ty, .. }) = input - && let Type::Path(type_path) = ty.as_ref() - { - let path_segments = &type_path.path; - if !path_segments.segments.is_empty() { - let segment = path_segments.segments.last().unwrap(); - if segment.ident == "Path" - && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - path_extractor_type = Some(inner_ty.clone()); - break; - } - } - } - } - - // Generate path parameters from path string (not from function signature) - // This is the primary source of truth for path parameters - if !path_params.is_empty() { - if let Some(ty) = path_extractor_type { - // Check if it's a tuple type - if let Type::Tuple(tuple) = ty { - // For tuple types, match each path parameter with tuple element type - for (idx, param_name) in path_params.iter().enumerate() { - if let Some(elem_ty) = tuple.elems.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - )), - example: None, - }); - } else { - // If tuple doesn't have enough elements, use String as default - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &syn::parse_str::("String").unwrap(), - known_schemas, - struct_definitions, - )), - example: None, - }); - } - } - } else { - // Single path parameter - if path_params.len() == 1 { - parameters.push(Parameter { - name: path_params[0].clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &ty, - known_schemas, - struct_definitions, - )), - example: None, - }); - } else { - // Multiple path parameters but single type - use String for all - for param_name in &path_params { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &ty, - known_schemas, - struct_definitions, - )), - example: None, - }); - } - } - } - } else { - // No Path extractor found, but path has parameters - use String as default - for param_name in &path_params { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &syn::parse_str::("String").unwrap(), - known_schemas, - struct_definitions, - )), - example: None, - }); - } - } - } - - // Parse function parameters (skip Path extractor as we already handled it) - 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) { - request_body = Some(body); - } else { - // Skip Path extractor - we already handled path parameters above - let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input - && let Type::Path(type_path) = ty.as_ref() - && !&type_path.path.segments.is_empty() - { - let segment = &type_path.path.segments.last().unwrap(); - segment.ident == "Path" - } else { - false - }; - - if !is_path_extractor - && let Some(params) = - parse_function_parameter(input, &path_params, known_schemas, struct_definitions) - { - parameters.extend(params); - } - } - } - - // Fallback: if last arg is String/&str and no body yet, treat as text/plain body - if request_body.is_none() { - if let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { - let is_string = match ty.as_ref() { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false), - Type::Reference(type_ref) => { - if let Type::Path(p) = type_ref.elem.as_ref() { - p.path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false) - } else { - false - } - } - _ => false, - }; - - if is_string { - let mut content = BTreeMap::new(); - content.insert( - "text/plain".to_string(), - MediaType { - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - examples: None, - }, - ); - request_body = Some(RequestBody { - description: None, - content, - required: Some(true), - }); - } - } - } - - // Parse return type - may return multiple responses (for Result types) - 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 { - // Find the error response schema (usually 400 or the first error response) - let error_schema = responses - .iter() - .find(|(code, _)| code != &&"200".to_string()) - .and_then(|(_, resp)| { - resp.content - .as_ref()? - .get("application/json")? - .schema - .clone() - }); - - if let Some(schema) = error_schema { - for &status_code in status_codes { - let status_str = status_code.to_string(); - // Only add if not already present - responses.entry(status_str).or_insert_with(|| { - let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(schema.clone()), - example: None, - examples: None, - }, - ); - - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - } - }); - } - } - } - - Operation { - operation_id: Some(sig.ident.to_string()), - tags: None, - summary: None, - description: None, - parameters: if parameters.is_empty() { - None - } else { - Some(parameters) - }, - request_body, - responses, - security: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use rstest::rstest; - use std::collections::HashMap; - use vespera_core::schema::SchemaType; - - #[rstest] - #[case("/test", vec![])] - #[case("/test/{id}", vec!["id"])] - #[case("/test/{id}/test/{test_id}", vec!["id", "test_id"])] - #[case("/test/:id/test/:test_id", vec!["id", "test_id"])] - fn test_extract_path_parameters(#[case] path: &str, #[case] expected: Vec<&str>) { - assert_eq!(extract_path_parameters(path), expected); - } - - #[rstest] - #[case("", "")] // No return type - #[case("-> String", "String")] // Simple return type - #[case("-> i32", "i32")] // Integer return type - #[case("-> bool", "bool")] // Boolean return type - #[case("-> Vec", "Vec")] // Array return type - #[case("-> Option", "Option")] // Option return type - #[case("-> Result", "Result")] // Result with same types - #[case("-> Result", "Result")] // Result with different types - #[case("-> Result, String>", "Result, String>")] // Result with Json wrapper - #[case( - "-> Result", - "Result" - )] // Result with status code tuple - #[case("-> &str", "&str")] // Reference return type - #[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 - } else { - // Parse the return type from string - let full_signature = format!("fn test() {}", return_type_str); - let parsed: syn::Signature = - syn::parse_str(&full_signature).expect("Failed to parse return type"); - parsed.output - }; - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - - match expected_type { - "" => { - // ReturnType::Default - should have 200 with no content - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_none()); - } - "String" | "&str" => { - // String return type - should have 200 with String schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema"); - } - } - "i32" => { - // Integer return type - should have 200 with Integer schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline Integer schema"); - } - } - "bool" => { - // Boolean return type - should have 200 with Boolean schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Boolean)); - } else { - panic!("Expected inline Boolean schema"); - } - } - "Vec" => { - // Array return type - should have 200 with Array schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert!(schema.items.is_some()); - // Check that items is String - if let Some(items) = &schema.items { - if let SchemaRef::Inline(items_schema) = items.as_ref() { - assert_eq!(items_schema.schema_type, Some(SchemaType::String)); - } - } - } else { - panic!("Expected inline Array schema"); - } - } - "Option" => { - // Option return type - should have 200 with nullable String schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.nullable, Some(true)); - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline nullable String schema"); - } - } - "Result" => { - // Result types - should have 200 for Ok and 400 for Err - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result" => { - // Result types - should have 200 for Ok and 400 for Err - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline Integer schema for Ok type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result, String>" => { - // Result with Json wrapper - should unwrap Json - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - // User is not in known_schemas, so it should be an object schema - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - } else { - panic!("Expected inline Object schema for User type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result<&str, String>" => { - // Result with reference - should handle reference correctly - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for &str type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result" => { - // Result with status code tuple - should use status code from tuple - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); // Default status code from tuple - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - _ => panic!("Unexpected test case"), - } - } - - #[rstest] - #[case( - "fn test(params: Path<(String, i32)>) {}", - vec!["user_id".to_string(), "count".to_string()], - vec![vec![ParameterLocation::Path, ParameterLocation::Path]] - )] - #[case( - "fn show(Path(id): Path) {}", - vec!["item_id".to_string()], // path string name differs from pattern - vec![vec![ParameterLocation::Path]] // expect path param captured - )] - #[case( - "fn test(Query(params): Query>) {}", - vec![], - vec![vec![]] // Query> is ignored - )] - #[case( - "fn test(Header(token): Header, count: i32) {}", - vec![], - vec![ - vec![ParameterLocation::Header], // first arg (Header) - vec![], // second arg (primitive, ignored) - ] - )] - fn test_parse_function_parameter_cases( - #[case] func_src: &str, - #[case] path_params: Vec, - #[case] expected_locations: Vec>, - ) { - let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); - for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = - parse_function_parameter(arg, &path_params, &HashMap::new(), &HashMap::new()); - let expected = expected_locations - .get(idx) - .unwrap_or_else(|| expected_locations.last().unwrap()); - - if expected.is_empty() { - assert!( - result.is_none(), - "Expected None at arg index {}, func: {}", - idx, - func_src - ); - continue; - } - - let params = result.as_ref().expect("Expected Some parameters"); - let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); - assert_eq!( - got_locs, *expected, - "Location mismatch at arg index {idx}, func: {func_src}" - ); - } - } - - #[rstest] - #[case("fn test(Json(payload): Json) {}", true)] - #[case("fn test(not_json: String) {}", false)] - fn test_parse_request_body_json_cases(#[case] func_src: &str, #[case] has_body: bool) { - let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); - let arg = func.sig.inputs.first().unwrap(); - let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); - assert_eq!(body.is_some(), has_body); - } - - #[rstest] - #[case("HashMap", Some(SchemaType::Object), true)] - #[case("Option", Some(SchemaType::String), false)] // nullable check - fn test_parse_type_to_schema_ref_cases( - #[case] ty_src: &str, - #[case] expected_type: Option, - #[case] expect_additional_props: bool, - ) { - let ty: Type = syn::parse_str(ty_src).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = schema_ref { - assert_eq!(schema.schema_type, expected_type); - if expect_additional_props { - assert!(schema.additional_properties.is_some()); - } - if ty_src.starts_with("Option") { - assert_eq!(schema.nullable, Some(true)); - } - } else { - panic!("Expected inline schema for {}", ty_src); - } - } - - #[test] - fn test_parse_type_to_schema_ref_generic_substitution() { - // Ensure generic struct Wrapper { value: T } is substituted to concrete type - let mut known_schemas = HashMap::new(); - known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string()); - - let mut struct_definitions = HashMap::new(); - struct_definitions.insert( - "Wrapper".to_string(), - "struct Wrapper { value: T }".to_string(), - ); - - let ty: Type = syn::parse_str("Wrapper").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions); - - if let SchemaRef::Inline(schema) = schema_ref { - let props = schema.properties.as_ref().unwrap(); - let value = props.get("value").unwrap(); - if let SchemaRef::Inline(inner) = value { - assert_eq!(inner.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline schema for value"); - } - } else { - panic!("Expected inline schema for generic substitution"); - } - } - - #[test] - fn test_build_operation_string_body_fallback() { - let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); - let op = - build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); - - // Ensure body is set as text/plain - let body = op.request_body.as_ref().expect("request body expected"); - assert!(body.content.contains_key("text/plain")); - let media = body.content.get("text/plain").unwrap(); - if let SchemaRef::Inline(schema) = media.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("inline string schema expected"); - } - - // No parameters should be present - assert!(op.parameters.is_none()); - } - - #[rstest] - #[case("-> User", Some("User"), None, None, false, None)] // known schema ref - #[case("-> Json", Some("User"), None, None, false, None)] // unwrap Json, known ref - #[case("-> Result", Some("User"), None, Some("Error"), false, None)] // Ok/Err refs - #[case( - "-> Result<(HeaderMap, String), String>", - None, - Some(SchemaType::String), - None, - true, - Some(SchemaType::String) - )] // HeaderMap sets headers, Ok body is String - #[case( - "-> Result<(StatusCode, HeaderMap, String), String>", - None, - Some(SchemaType::String), - None, - true, - Some(SchemaType::String) - )] - #[case( - "-> Result<(StatusCode, HeaderMap, u32, String), String>", - None, - Some(SchemaType::String), - None, - true, - Some(SchemaType::String) - )] - fn test_parse_return_type_additional_cases( - #[case] return_type_str: &str, - #[case] expect_ok_ref: Option<&str>, - #[case] expect_ok_type: Option, - #[case] expect_err_ref: Option<&str>, - #[case] expect_ok_headers: bool, - #[case] expect_err_type: Option, - ) { - 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 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, &struct_definitions); - - // Ok response - let ok_response = responses.get("200").expect("Ok response missing"); - if expect_ok_headers { - assert!(ok_response.headers.is_some(), "Expected headers set for Ok"); - } else { - assert!(ok_response.headers.is_none(), "Headers should be None for Ok"); - } - let ok_content = ok_response.content.as_ref().expect("Ok content missing"); - let ok_media_type = ok_content - .get("application/json") - .expect("Ok application/json missing"); - if let Some(expected_ref) = expect_ok_ref { - if let SchemaRef::Ref(r) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(r.ref_path, format!("#/components/schemas/{}", expected_ref)); - } else { - panic!("Expected schema ref for Ok"); - } - } else if let Some(expected_type) = expect_ok_type { - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(expected_type)); - } else { - panic!("Expected inline schema for Ok"); - } - } - - // Err response (if present) - if let Some(expected_err_ref) = expect_err_ref { - let err_response = responses.get("400").expect("Err response missing"); - let err_content = err_response.content.as_ref().expect("Err content missing"); - let err_media_type = err_content - .get("application/json") - .expect("Err application/json missing"); - if let SchemaRef::Ref(r) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(r.ref_path, format!("#/components/schemas/{}", expected_err_ref)); - } else { - panic!("Expected schema ref for Err"); - } - } else if let Some(expected_err_type) = expect_err_type { - let err_response = responses.get("400").expect("Err response missing"); - let err_content = err_response.content.as_ref().expect("Err content missing"); - let err_media_type = err_content - .get("application/json") - .expect("Err application/json missing"); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(expected_err_type)); - } else { - panic!("Expected inline schema for Err"); - } - } - } - - #[rstest] - // camelCase tests - #[case("user_name", Some("camelCase"), "userName")] - #[case("first_name", Some("camelCase"), "firstName")] - #[case("last_name", Some("camelCase"), "lastName")] - #[case("user_id", Some("camelCase"), "userId")] - #[case("api_key", Some("camelCase"), "apiKey")] - #[case("already_camel", Some("camelCase"), "alreadyCamel")] - // snake_case tests - #[case("userName", Some("snake_case"), "user_name")] - #[case("firstName", Some("snake_case"), "first_name")] - #[case("lastName", Some("snake_case"), "last_name")] - #[case("userId", Some("snake_case"), "user_id")] - #[case("apiKey", Some("snake_case"), "api_key")] - #[case("already_snake", Some("snake_case"), "already_snake")] - // kebab-case tests - #[case("user_name", Some("kebab-case"), "user-name")] - #[case("first_name", Some("kebab-case"), "first-name")] - #[case("last_name", Some("kebab-case"), "last-name")] - #[case("user_id", Some("kebab-case"), "user-id")] - #[case("api_key", Some("kebab-case"), "api-key")] - #[case("already-kebab", Some("kebab-case"), "already-kebab")] - // PascalCase tests - #[case("user_name", Some("PascalCase"), "UserName")] - #[case("first_name", Some("PascalCase"), "FirstName")] - #[case("last_name", Some("PascalCase"), "LastName")] - #[case("user_id", Some("PascalCase"), "UserId")] - #[case("api_key", Some("PascalCase"), "ApiKey")] - #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] - // lowercase tests - #[case("UserName", Some("lowercase"), "username")] - #[case("FIRST_NAME", Some("lowercase"), "first_name")] - #[case("lastName", Some("lowercase"), "lastname")] - #[case("User_ID", Some("lowercase"), "user_id")] - #[case("API_KEY", Some("lowercase"), "api_key")] - #[case("already_lower", Some("lowercase"), "already_lower")] - // UPPERCASE tests - #[case("user_name", Some("UPPERCASE"), "USER_NAME")] - #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] - #[case("LastName", Some("UPPERCASE"), "LASTNAME")] - #[case("user_id", Some("UPPERCASE"), "USER_ID")] - #[case("apiKey", Some("UPPERCASE"), "APIKEY")] - #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] - // SCREAMING_SNAKE_CASE tests - #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] - #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] - #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] - #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] - #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] - #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] - // SCREAMING-KEBAB-CASE tests - #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] - #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] - #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] - #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] - #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] - #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] - // None tests (no transformation) - #[case("user_name", None, "user_name")] - #[case("firstName", None, "firstName")] - #[case("LastName", None, "LastName")] - #[case("user-id", None, "user-id")] - fn test_rename_field( - #[case] field_name: &str, - #[case] rename_all: Option<&str>, - #[case] expected: &str, - ) { - assert_eq!(rename_field(field_name, rename_all), expected); - } -} diff --git a/crates/vespera_macro/src/parser/mod.rs b/crates/vespera_macro/src/parser/mod.rs new file mode 100644 index 0000000..9a638c7 --- /dev/null +++ b/crates/vespera_macro/src/parser/mod.rs @@ -0,0 +1,10 @@ +mod operation; +mod parameters; +mod path; +mod request_body; +mod response; +mod schema; + +pub use operation::build_operation_from_function; +pub use schema::{parse_enum_to_schema, parse_struct_to_schema}; + diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs new file mode 100644 index 0000000..08b16ca --- /dev/null +++ b/crates/vespera_macro/src/parser/operation.rs @@ -0,0 +1,291 @@ +use std::collections::BTreeMap; + +use syn::{FnArg, PatType, Type}; +use vespera_core::{ + route::{MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response}, + schema::{Schema, SchemaRef}, +}; + +use super::{ + parameters::parse_function_parameter, + path::extract_path_parameters, + request_body::parse_request_body, + response::parse_return_type, + schema::parse_type_to_schema_ref_with_schemas, +}; + +/// Build Operation from function signature +pub fn build_operation_from_function( + sig: &syn::Signature, + path: &str, + known_schemas: &std::collections::HashMap, + struct_definitions: &std::collections::HashMap, + error_status: Option<&[u16]>, +) -> Operation { + let path_params = extract_path_parameters(path); + let mut parameters = Vec::new(); + let mut request_body = None; + let mut path_extractor_type: Option = None; + + // First pass: find Path extractor and extract its type + for input in &sig.inputs { + if let FnArg::Typed(PatType { ty, .. }) = input + && let Type::Path(type_path) = ty.as_ref() + { + let path_segments = &type_path.path; + if !path_segments.segments.is_empty() { + let segment = path_segments.segments.last().unwrap(); + if segment.ident == "Path" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + path_extractor_type = Some(inner_ty.clone()); + break; + } + } + } + } + + // Generate path parameters from path string (not from function signature) + // This is the primary source of truth for path parameters + if !path_params.is_empty() { + if let Some(ty) = path_extractor_type { + // Check if it's a tuple type + if let Type::Tuple(tuple) = ty { + // For tuple types, match each path parameter with tuple element type + for (idx, param_name) in path_params.iter().enumerate() { + if let Some(elem_ty) = tuple.elems.get(idx) { + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + )), + example: None, + }); + } else { + // If tuple doesn't have enough elements, use String as default + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &syn::parse_str::("String").unwrap(), + known_schemas, + struct_definitions, + )), + example: None, + }); + } + } + } else { + // Single path parameter + if path_params.len() == 1 { + parameters.push(Parameter { + name: path_params[0].clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &ty, + known_schemas, + struct_definitions, + )), + example: None, + }); + } else { + // Multiple path parameters but single type - use String for all + for param_name in &path_params { + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &ty, + known_schemas, + struct_definitions, + )), + example: None, + }); + } + } + } + } else { + // No Path extractor found, but path has parameters - use String as default + for param_name in &path_params { + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &syn::parse_str::("String").unwrap(), + known_schemas, + struct_definitions, + )), + example: None, + }); + } + } + } + + // Parse function parameters (skip Path extractor as we already handled it) + 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) { + request_body = Some(body); + } else { + // Skip Path extractor - we already handled path parameters above + let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input + && let Type::Path(type_path) = ty.as_ref() + && !&type_path.path.segments.is_empty() + { + let segment = &type_path.path.segments.last().unwrap(); + segment.ident == "Path" + } else { + false + }; + + if !is_path_extractor + && let Some(params) = + parse_function_parameter(input, &path_params, known_schemas, struct_definitions) + { + parameters.extend(params); + } + } + } + + // Fallback: if last arg is String/&str and no body yet, treat as text/plain body + if request_body.is_none() { + if let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { + let is_string = match ty.as_ref() { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false), + Type::Reference(type_ref) => { + if let Type::Path(p) = type_ref.elem.as_ref() { + p.path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false) + } else { + false + } + } + _ => false, + }; + + if is_string { + let mut content = BTreeMap::new(); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + examples: None, + }, + ); + request_body = Some(RequestBody { + description: None, + content, + required: Some(true), + }); + } + } + } + + // Parse return type - may return multiple responses (for Result types) + 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 { + // Find the error response schema (usually 400 or the first error response) + let error_schema = responses + .iter() + .find(|(code, _)| code != &&"200".to_string()) + .and_then(|(_, resp)| { + resp.content + .as_ref()? + .get("application/json")? + .schema + .clone() + }); + + if let Some(schema) = error_schema { + for &status_code in status_codes { + let status_str = status_code.to_string(); + // Only add if not already present + responses.entry(status_str).or_insert_with(|| { + let mut err_content = BTreeMap::new(); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema.clone()), + example: None, + examples: None, + }, + ); + + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + } + }); + } + } + } + + Operation { + operation_id: Some(sig.ident.to_string()), + tags: None, + summary: None, + description: None, + parameters: if parameters.is_empty() { + None + } else { + Some(parameters) + }, + request_body, + responses, + security: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use vespera_core::schema::{SchemaRef, SchemaType}; + + #[test] + fn test_build_operation_string_body_fallback() { + let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); + let op = + build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); + + // Ensure body is set as text/plain + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + let media = body.content.get("text/plain").unwrap(); + if let SchemaRef::Inline(schema) = media.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("inline string schema expected"); + } + + // No parameters should be present + assert!(op.parameters.is_none()); + } +} + diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs new file mode 100644 index 0000000..b6a20c6 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -0,0 +1,517 @@ +use std::collections::HashMap; + +use syn::{FnArg, Pat, PatType, Type}; +use vespera_core::{ + route::{Parameter, ParameterLocation}, + schema::{Schema, SchemaRef, SchemaType}, +}; + +use super::schema::{ + extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, + parse_type_to_schema_ref_with_schemas, rename_field, +}; + +/// 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> { + match arg { + FnArg::Receiver(_) => None, + FnArg::Typed(PatType { pat, ty, .. }) => { + // Extract parameter name from pattern + let param_name = match pat.as_ref() { + Pat::Ident(ident) => ident.ident.to_string(), + Pat::TupleStruct(tuple_struct) => { + // Handle Path(id) pattern + if tuple_struct.elems.len() == 1 + && let Pat::Ident(ident) = &tuple_struct.elems[0] + { + ident.ident.to_string() + } else { + return None; + } + } + _ => return None, + }; + + // Check for Option> first + if let Type::Path(type_path) = ty.as_ref() { + let path = &type_path.path; + if !path.segments.is_empty() { + let segment = path.segments.first().unwrap(); + let ident_str = segment.ident.to_string(); + + // Handle Option> + if ident_str == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Type::Path(inner_type_path) = inner_ty + && !inner_type_path.path.segments.is_empty() + { + let inner_segment = inner_type_path.path.segments.last().unwrap(); + let inner_ident_str = inner_segment.ident.to_string(); + + if inner_ident_str == "TypedHeader" { + // TypedHeader always uses string schema regardless of inner type + return Some(vec![Parameter { + name: param_name.replace("_", "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(false), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + }]); + } + } + } + } + } + + // 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() { + // 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() { + "Path" => { + // Path extractor - use path parameter name from route if available + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() + { + // Check if inner type is a tuple (e.g., Path<(String, String, String)>) + if let Type::Tuple(tuple) = inner_ty { + // For tuple types, extract parameters from path string + let mut parameters = Vec::new(); + let tuple_elems = &tuple.elems; + + // Match tuple elements with path parameters + for (idx, elem_ty) in tuple_elems.iter().enumerate() { + if let Some(param_name) = path_params.get(idx) { + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some( + parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + ), + ), + example: None, + }); + } + } + + if !parameters.is_empty() { + return Some(parameters); + } + } else { + // Single path parameter + // If there's exactly one path parameter, use its name + let name = if path_params.len() == 1 { + path_params[0].clone() + } else { + // Otherwise use the parameter name from the pattern + param_name + }; + return Some(vec![Parameter { + name, + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); + } + } + } + "Query" => { + // Query extractor + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() + { + // 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, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); + } + } + "Header" => { + // Header extractor + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() + { + return Some(vec![Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); + } + } + "TypedHeader" => { + // TypedHeader extractor (axum::TypedHeader) + // TypedHeader always uses string schema regardless of inner type + return Some(vec![Parameter { + name: param_name.replace("_", "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + }]); + } + "Json" => { + // Json extractor - this will be handled as RequestBody + return None; + } + _ => {} + } + } + } + + // Check if it's a path parameter (by name match) - for non-extractor cases + if path_params.contains(¶m_name) { + return Some(vec![Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); + } + + // Bare primitive without extractor is ignored (cannot infer location) + None + } + } +} + +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 +} + +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) + + // Check if it's in struct_definitions or known_schemas + if struct_definitions.contains_key(&ident_str) || known_schemas.contains_key(&ident_str) { + 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) + + // Check if it's a known struct + if let Some(struct_def) = struct_definitions.get(&ident_str) + && 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/") + && let Some(struct_def) = struct_definitions.get(type_name) + && 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 +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::collections::HashMap; + use vespera_core::route::ParameterLocation; + use serial_test::serial; + #[rstest] + #[case( + "fn test(params: Path<(String, i32)>) {}", + vec!["user_id".to_string(), "count".to_string()], + vec![vec![ParameterLocation::Path, ParameterLocation::Path]] + )] + #[case( + "fn show(Path(id): Path) {}", + vec!["item_id".to_string()], // path string name differs from pattern + vec![vec![ParameterLocation::Path]] // expect path param captured + )] + #[case( + "fn test(Query(params): Query>) {}", + vec![], + vec![vec![]] // Query> is ignored + )] + #[case( + "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", + vec![], + vec![ + vec![ParameterLocation::Header], // first arg (TypedHeader) + vec![], // second arg (primitive, ignored) + ] + )] + #[case( + "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", + vec![], + vec![ + vec![ParameterLocation::Header], // first arg (TypedHeader) + vec![ParameterLocation::Header], // second arg (TypedHeader) + vec![ParameterLocation::Header], // third arg (TypedHeader) + ] + )] + #[case( + "fn test(user_agent: TypedHeader, count: i32) {}", + vec![], + vec![ + vec![ParameterLocation::Header], // first arg (TypedHeader) + vec![], // second arg (primitive, ignored) + ] + )] + #[serial] + fn test_parse_function_parameter_cases( + #[case] func_src: &str, + #[case] path_params: Vec, + #[case] expected_locations: Vec>, + ) { + let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + for (idx, arg) in func.sig.inputs.iter().enumerate() { + use insta::assert_debug_snapshot; + + let result = + parse_function_parameter(arg, &path_params, &HashMap::new(), &HashMap::new()); + let expected = expected_locations + .get(idx) + .unwrap_or_else(|| expected_locations.last().unwrap()); + + if expected.is_empty() { + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}", + idx, + func_src + ); + continue; + } + + let params = result.as_ref().expect("Expected Some parameters"); + let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); + assert_eq!( + got_locs, *expected, + "Location mismatch at arg index {idx}, func: {func_src}" + ); + assert_debug_snapshot!(params); + } + } +} + diff --git a/crates/vespera_macro/src/parser/path.rs b/crates/vespera_macro/src/parser/path.rs new file mode 100644 index 0000000..51413f5 --- /dev/null +++ b/crates/vespera_macro/src/parser/path.rs @@ -0,0 +1,33 @@ +/// Extract path parameters from a path string +pub fn extract_path_parameters(path: &str) -> Vec { + let mut params = Vec::new(); + let segments: Vec<&str> = path.split('/').collect(); + + for segment in segments { + if segment.starts_with('{') && segment.ends_with('}') { + let param = segment.trim_start_matches('{').trim_end_matches('}'); + params.push(param.to_string()); + } else if segment.starts_with(':') { + let param = segment.trim_start_matches(':'); + params.push(param.to_string()); + } + } + + params +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("/test", vec![])] + #[case("/test/{id}", vec!["id"])] + #[case("/test/{id}/test/{test_id}", vec!["id", "test_id"])] + #[case("/test/:id/test/:test_id", vec!["id", "test_id"])] + fn test_extract_path_parameters(#[case] path: &str, #[case] expected: Vec<&str>) { + assert_eq!(extract_path_parameters(path), expected); + } +} + diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs new file mode 100644 index 0000000..8591aba --- /dev/null +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeMap; + +use syn::{FnArg, PatType, Type}; +use vespera_core::route::{MediaType, RequestBody}; + +use super::schema::parse_type_to_schema_ref_with_schemas; + +fn is_string_like(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|seg| seg.ident == "String" || seg.ident == "str") + .unwrap_or(false), + Type::Reference(type_ref) => is_string_like(&type_ref.elem), + _ => false, + } +} + +/// Analyze function signature and extract RequestBody +pub fn parse_request_body( + arg: &FnArg, + known_schemas: &std::collections::HashMap, + struct_definitions: &std::collections::HashMap, +) -> Option { + match arg { + FnArg::Receiver(_) => None, + FnArg::Typed(PatType { ty, .. }) => { + if let Type::Path(type_path) = ty.as_ref() { + let path = &type_path.path; + if path.segments.is_empty() { + return None; + } + + // 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_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + ); + let mut content = BTreeMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); + } + } + + if is_string_like(ty.as_ref()) { + let schema = + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); + let mut content = BTreeMap::new(); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); + } + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::collections::HashMap; + use vespera_core::schema::{SchemaRef, SchemaType}; + use insta::assert_debug_snapshot; + use serial_test::serial; + + #[rstest] + #[case::json("fn test(Json(payload): Json) {}", true)] + #[case::string("fn test(just_string: String) {}", true)] + #[case::str("fn test(just_str: &str) {}", true)] + #[case::i32("fn test(just_i32: i32) {}", false)] + #[serial] + fn test_parse_request_body_cases(#[case] func_src: &str, #[case] has_body: bool) { + let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + let arg = func.sig.inputs.first().unwrap(); + let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); + assert_eq!(body.is_some(), has_body); + assert_debug_snapshot!(body); + } + + #[test] + fn test_parse_request_body_text_plain_schema() { + let func: syn::ItemFn = syn::parse_str("fn test(body: &str) {}").unwrap(); + let arg = func.sig.inputs.first().unwrap(); + let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()) + .expect("expected request body"); + + let media = body + .content + .get("text/plain") + .expect("expected text/plain content"); + + if let SchemaRef::Inline(schema) = media.schema.as_ref().expect("schema expected") { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("expected inline schema"); + } + } +} + diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs new file mode 100644 index 0000000..2141b45 --- /dev/null +++ b/crates/vespera_macro/src/parser/response.rs @@ -0,0 +1,548 @@ +use std::collections::{BTreeMap, HashMap}; + +use syn::{ReturnType, Type}; +use vespera_core::route::{Header, MediaType, Response}; + +use super::schema::parse_type_to_schema_ref_with_schemas; + +/// 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() { + // 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; + } + } + } + ty +} + +/// Extract Ok and Err types from Result or Result, E> +/// Handles both Result and std::result::Result, and unwraps references +fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { + // First unwrap Json if present + let unwrapped = unwrap_json(ty); + + // Handle both Type::Path and Type::Reference (for &Result<...>) + let result_type = match unwrapped { + Type::Path(type_path) => type_path, + Type::Reference(type_ref) => { + // Unwrap reference and check if it's a Result + if let Type::Path(type_path) = type_ref.elem.as_ref() { + type_path + } else { + return None; + } + } + _ => return None, + }; + + let path = &result_type.path; + if path.segments.is_empty() { + return None; + } + + // Check if any segment is "Result" (handles both Result and std::result::Result) + let is_result = path.segments.iter().any(|seg| seg.ident == "Result"); + + if is_result { + // 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())); + } + } + None +} + +/// Check if error type is a tuple (StatusCode, E) or (StatusCode, Json) +/// 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 + && let Type::Path(type_path) = &tuple.elems[0]&& !&type_path.path.segments.is_empty() { + let path = &type_path.path; + 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 +} + +fn is_header_map_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return false; + } + return path.segments.iter().any(|s| s.ident == "HeaderMap"); + } + false +} + +/// Extract payload type from an Ok tuple and track if headers exist. +/// The last element of the tuple is always treated as the response body. +/// Any presence of HeaderMap in the tuple marks headers as present. +fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { + if let Type::Tuple(tuple) = ok_ty { + let payload_ty = tuple.elems.last().map(|ty| unwrap_json(ty).clone()); + let has_headers = tuple.elems.iter().any(is_header_map_type); + + if let Some(payload_ty) = payload_ty { + let headers = if has_headers { + Some(HashMap::new()) + } else { + None + }; + return (payload_ty, headers); + } + } + + (ok_ty.clone(), None) +} + +/// Analyze return type and convert to Responses map +pub fn parse_return_type( + return_type: &ReturnType, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> BTreeMap { + let mut responses = BTreeMap::new(); + + match return_type { + ReturnType::Default => { + // No return type - just 200 with no content + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: None, + content: None, + }, + ); + } + ReturnType::Type(_, ty) => { + // Check if it's a Result + if let Some((ok_ty, err_ty)) = extract_result_types(ty) { + // Handle success response (200) + let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); + let ok_schema = parse_type_to_schema_ref_with_schemas( + &ok_payload_ty, + known_schemas, + struct_definitions, + ); + let mut ok_content = BTreeMap::new(); + ok_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(ok_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: ok_headers, + content: Some(ok_content), + }, + ); + + // Handle error response + // 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_with_schemas( + &error_type, + known_schemas, + struct_definitions, + ); + let mut err_content = BTreeMap::new(); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(err_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + status_code.to_string(), + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + }, + ); + } else { + // 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_with_schemas( + err_ty_unwrapped, + known_schemas, + struct_definitions, + ); + let mut err_content = BTreeMap::new(); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(err_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "400".to_string(), + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + }, + ); + } + } else { + // Not a Result type - regular response + // Unwrap Json if present + let unwrapped_ty = unwrap_json(ty); + 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(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: None, + content: Some(content), + }, + ); + } + } + } + + responses +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::collections::HashMap; + use vespera_core::schema::{SchemaRef, SchemaType}; + + #[rstest] + #[case("", "")] // No return type + #[case("-> String", "String")] // Simple return type + #[case("-> i32", "i32")] // Integer return type + #[case("-> bool", "bool")] // Boolean return type + #[case("-> Vec", "Vec")] // Array return type + #[case("-> Option", "Option")] // Option return type + #[case("-> Result", "Result")] // Result with same types + #[case("-> Result", "Result")] // Result with different types + #[case("-> Result, String>", "Result, String>")] // Result with Json wrapper + #[case( + "-> Result", + "Result" + )] // Result with status code tuple + #[case("-> &str", "&str")] // Reference return type + #[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() { + syn::ReturnType::Default + } else { + // Parse the return type from string + let full_signature = format!("fn test() {}", return_type_str); + let parsed: syn::Signature = + syn::parse_str(&full_signature).expect("Failed to parse return type"); + parsed.output + }; + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + match expected_type { + "" => { + // ReturnType::Default - should have 200 with no content + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_none()); + } + "String" | "&str" => { + // String return type - should have 200 with String schema + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_some()); + let content = response.content.as_ref().unwrap(); + assert!(content.contains_key("application/json")); + let media_type = content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema"); + } + } + "i32" => { + // Integer return type - should have 200 with Integer schema + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_some()); + let content = response.content.as_ref().unwrap(); + assert!(content.contains_key("application/json")); + let media_type = content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline Integer schema"); + } + } + "bool" => { + // Boolean return type - should have 200 with Boolean schema + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_some()); + let content = response.content.as_ref().unwrap(); + assert!(content.contains_key("application/json")); + let media_type = content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::Boolean)); + } else { + panic!("Expected inline Boolean schema"); + } + } + "Vec" => { + // Array return type - should have 200 with Array schema + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_some()); + let content = response.content.as_ref().unwrap(); + assert!(content.contains_key("application/json")); + let media_type = content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert!(schema.items.is_some()); + // Check that items is String + if let Some(items) = &schema.items { + if let SchemaRef::Inline(items_schema) = items.as_ref() { + assert_eq!(items_schema.schema_type, Some(SchemaType::String)); + } + } + } else { + panic!("Expected inline Array schema"); + } + } + "Option" => { + // Option return type - should have 200 with nullable String schema + assert_eq!(responses.len(), 1); + assert!(responses.contains_key("200")); + let response = responses.get("200").unwrap(); + assert_eq!(response.description, "Successful response"); + assert!(response.content.is_some()); + let content = response.content.as_ref().unwrap(); + assert!(content.contains_key("application/json")); + let media_type = content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { + assert_eq!(schema.nullable, Some(true)); + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline nullable String schema"); + } + } + "Result" => { + // Result types - should have 200 for Ok and 400 for Err + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("400")); + + let ok_response = responses.get("200").unwrap(); + assert_eq!(ok_response.description, "Successful response"); + assert!(ok_response.content.is_some()); + let ok_content = ok_response.content.as_ref().unwrap(); + let ok_media_type = ok_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Ok type"); + } + + let err_response = responses.get("400").unwrap(); + assert_eq!(err_response.description, "Error response"); + assert!(err_response.content.is_some()); + let err_content = err_response.content.as_ref().unwrap(); + let err_media_type = err_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Err type"); + } + } + "Result" => { + // Result types - should have 200 for Ok and 400 for Err + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("400")); + + let ok_response = responses.get("200").unwrap(); + assert_eq!(ok_response.description, "Successful response"); + assert!(ok_response.content.is_some()); + let ok_content = ok_response.content.as_ref().unwrap(); + let ok_media_type = ok_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline Integer schema for Ok type"); + } + + let err_response = responses.get("400").unwrap(); + assert_eq!(err_response.description, "Error response"); + assert!(err_response.content.is_some()); + let err_content = err_response.content.as_ref().unwrap(); + let err_media_type = err_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Err type"); + } + } + "Result, String>" => { + // Result with Json wrapper - should unwrap Json + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("400")); + + let ok_response = responses.get("200").unwrap(); + assert_eq!(ok_response.description, "Successful response"); + assert!(ok_response.content.is_some()); + // User is not in known_schemas, so it should be an object schema + let ok_content = ok_response.content.as_ref().unwrap(); + let ok_media_type = ok_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + } else { + panic!("Expected inline Object schema for User type"); + } + + let err_response = responses.get("400").unwrap(); + assert_eq!(err_response.description, "Error response"); + assert!(err_response.content.is_some()); + let err_content = err_response.content.as_ref().unwrap(); + let err_media_type = err_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Err type"); + } + } + "Result<&str, String>" => { + // Result with reference - should handle reference correctly + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("400")); + + let ok_response = responses.get("200").unwrap(); + assert_eq!(ok_response.description, "Successful response"); + assert!(ok_response.content.is_some()); + let ok_content = ok_response.content.as_ref().unwrap(); + let ok_media_type = ok_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for &str type"); + } + + let err_response = responses.get("400").unwrap(); + assert_eq!(err_response.description, "Error response"); + assert!(err_response.content.is_some()); + let err_content = err_response.content.as_ref().unwrap(); + let err_media_type = err_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Err type"); + } + } + "Result" => { + // Result with status code tuple - should use status code from tuple + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("400")); // Default status code from tuple + + let ok_response = responses.get("200").unwrap(); + assert_eq!(ok_response.description, "Successful response"); + let ok_content = ok_response.content.as_ref().unwrap(); + let ok_media_type = ok_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Ok type"); + } + + let err_response = responses.get("400").unwrap(); + assert_eq!(err_response.description, "Error response"); + let err_content = err_response.content.as_ref().unwrap(); + let err_media_type = err_content.get("application/json").unwrap(); + if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Err type"); + } + } + _ => panic!("Unexpected test case"), + } + } +} + diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs new file mode 100644 index 0000000..a8c8d35 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema.rs @@ -0,0 +1,825 @@ +use std::collections::{BTreeMap, HashMap}; + +use syn::{Fields, Type}; +use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; + +pub(super) fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + // Parse the attribute tokens manually + // Format: #[serde(rename_all = "camelCase")] + let tokens = attr.meta.require_list().ok()?; + let token_str = tokens.tokens.to_string(); + + // Look for rename_all = "..." pattern + if let Some(start) = token_str.find("rename_all") { + let remaining = &token_str[start + "rename_all".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = &remaining[equals_pos + 1..].trim(); + // Extract string value (remove quotes) + if value_part.starts_with('"') && value_part.ends_with('"') { + let value = &value_part[1..value_part.len() - 1]; + return Some(value.to_string()); + } + } + } + } + } + None +} + +pub(super) fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + // Try to parse as Meta::List first + if let syn::Meta::List(meta_list) = &attr.meta { + let tokens = meta_list.tokens.to_string(); + + // Look for rename = "..." pattern + if let Some(start) = tokens.find("rename") { + let remaining = &tokens[start + "rename".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = &remaining[equals_pos + 1..].trim(); + // Extract string value (remove quotes) + if value_part.starts_with('"') && value_part.ends_with('"') { + let value = &value_part[1..value_part.len() - 1]; + return Some(value.to_string()); + } + } + } + } + } + } + None +} + +pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { + // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" + match rename_all { + Some("camelCase") => { + // Convert snake_case to camelCase + let mut result = String::new(); + let mut capitalize_next = false; + for ch in field_name.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_uppercase().next().unwrap_or(ch)); + capitalize_next = false; + } else { + result.push(ch); + } + } + result + } + Some("snake_case") => { + // Convert camelCase to snake_case + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_lowercase().next().unwrap_or(ch)); + } + result + } + Some("kebab-case") => { + // Convert snake_case to kebab-case + field_name.replace('_', "-") + } + Some("PascalCase") => { + // Convert snake_case to PascalCase + let mut result = String::new(); + let mut capitalize_next = true; + for ch in field_name.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_uppercase().next().unwrap_or(ch)); + capitalize_next = false; + } else { + result.push(ch); + } + } + result + } + Some("lowercase") => { + // Convert to lowercase + field_name.to_lowercase() + } + Some("UPPERCASE") => { + // Convert to UPPERCASE + field_name.to_uppercase() + } + Some("SCREAMING_SNAKE_CASE") => { + // Convert to SCREAMING_SNAKE_CASE + // If already in SCREAMING_SNAKE_CASE format, return as is + if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') + { + return field_name.to_string(); + } + // First convert to snake_case if needed, then uppercase + let mut snake_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { + snake_case.push('_'); + } + if ch != '_' && ch != '-' { + snake_case.push(ch.to_lowercase().next().unwrap_or(ch)); + } else if ch == '_' { + snake_case.push('_'); + } + } + snake_case.to_uppercase() + } + Some("SCREAMING-KEBAB-CASE") => { + // Convert to SCREAMING-KEBAB-CASE + // First convert to kebab-case if needed, then uppercase + let mut kebab_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() + && i > 0 + && !kebab_case.ends_with('-') + && !kebab_case.ends_with('_') + { + kebab_case.push('-'); + } + if ch == '_' { + kebab_case.push('-'); + } else if ch != '-' { + kebab_case.push(ch.to_lowercase().next().unwrap_or(ch)); + } else { + kebab_case.push('-'); + } + } + kebab_case.to_uppercase() + } + _ => field_name.to_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); + + // Check if all variants are unit variants + let all_unit = enum_item + .variants + .iter() + .all(|v| matches!(v.fields, syn::Fields::Unit)); + + if all_unit { + // Simple enum with string values + let mut enum_values = Vec::new(); + + for variant in &enum_item.variants { + let variant_name = variant.ident.to_string(); + + // Check for variant-level rename attribute first (takes precedence) + let enum_value = if let Some(renamed) = extract_field_rename(&variant.attrs) { + renamed + } else { + // Apply rename_all transformation if present + rename_field(&variant_name, rename_all.as_deref()) + }; + + enum_values.push(serde_json::Value::String(enum_value)); + } + + Schema { + schema_type: Some(SchemaType::String), + r#enum: if enum_values.is_empty() { + None + } else { + Some(enum_values) + }, + ..Schema::string() + } + } else { + // Enum with data - use oneOf + let mut one_of_schemas = Vec::new(); + + for variant in &enum_item.variants { + let variant_name = variant.ident.to_string(); + + // Check for variant-level rename attribute first (takes precedence) + let variant_key = if let Some(renamed) = extract_field_rename(&variant.attrs) { + renamed + } else { + // Apply rename_all transformation if present + rename_field(&variant_name, rename_all.as_deref()) + }; + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant: {"const": "VariantName"} + Schema { + schema_type: Some(SchemaType::String), + r#enum: Some(vec![serde_json::Value::String(variant_key)]), + ..Schema::string() + } + } + syn::Fields::Unnamed(fields_unnamed) => { + // Tuple variant: {"VariantName": } + // For single field: {"VariantName": } + // For multiple fields: {"VariantName": [, , ...]} + 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, struct_definitions); + + let mut properties = BTreeMap::new(); + properties.insert(variant_key.clone(), inner_schema); + + Schema { + schema_type: Some(SchemaType::Object), + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } else { + // Multiple fields tuple variant - serialize as array + // serde serializes tuple variants as: {"VariantName": [value1, value2, ...]} + // 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, + ); + tuple_item_schemas.push(field_schema); + } + + let tuple_len = tuple_item_schemas.len(); + + // Create array schema with prefixItems for tuple arrays (OpenAPI 3.1) + let array_schema = Schema { + schema_type: Some(SchemaType::Array), + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, // Do not use prefixItems and items together + ..Schema::new(SchemaType::Array) + }; + + let mut properties = BTreeMap::new(); + properties.insert( + variant_key.clone(), + SchemaRef::Inline(Box::new(array_schema)), + ); + + Schema { + schema_type: Some(SchemaType::Object), + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + } + syn::Fields::Named(fields_named) => { + // Struct variant: {"VariantName": {field1: type1, field2: type2, ...}} + let mut variant_properties = BTreeMap::new(); + let mut variant_required = Vec::new(); + let variant_rename_all = extract_rename_all(&variant.attrs); + + 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, + variant_rename_all.as_deref().or(rename_all.as_deref()), + ) + }; + + let field_type = &field.ty; + let schema_ref = + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); + + variant_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 { + variant_required.push(field_name); + } + } + + // Wrap struct variant in an object with the variant name as key + let inner_struct_schema = Schema { + schema_type: Some(SchemaType::Object), + properties: if variant_properties.is_empty() { + None + } else { + Some(variant_properties) + }, + required: if variant_required.is_empty() { + None + } else { + Some(variant_required) + }, + ..Schema::object() + }; + + let mut properties = BTreeMap::new(); + properties.insert( + variant_key.clone(), + SchemaRef::Inline(Box::new(inner_struct_schema)), + ); + + Schema { + schema_type: Some(SchemaType::Object), + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, // oneOf doesn't have a single type + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + ..Schema::new(SchemaType::Object) + } + } +} + +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(); + + // 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() + } +} + +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()) +} + +pub(super) fn is_primitive_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.len() == 1 { + let ident = path.segments[0].ident.to_string(); + matches!( + ident.as_str(), + "i8" | "i16" + | "i32" + | "i64" + | "u8" + | "u16" + | "u32" + | "u64" + | "f32" + | "f64" + | "bool" + | "String" + | "str" + ) + } else { + false + } + } + _ => false, + } +} + +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) +} + +pub(super) 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; + if path.segments.is_empty() { + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + + // Get the last segment as the type name (handles paths like crate::TestStruct) + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Handle generic types + 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() { + 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 { + // Option -> nullable schema + if let SchemaRef::Inline(mut schema) = inner_schema { + schema.nullable = Some(true); + return SchemaRef::Inline(schema); + } + } + } + } + "HashMap" | "BTreeMap" => { + // HashMap or BTreeMap -> object with additionalProperties + // K is typically String, we use V as the value type + if args.args.len() >= 2 + && let ( + 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() + })); + } + } + _ => {} + } + } + + // Handle primitive types + match ident_str.as_str() { + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { + SchemaRef::Inline(Box::new(Schema::integer())) + } + "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), + "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), + "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), + // Standard library types that should not be referenced + // Note: HashMap and BTreeMap are handled above in generic types + "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { + // These are not schema types, return object schema + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + _ => { + // Check if this is a known schema (struct with Schema derive) + // Try both the full path and just the type name + let type_name = if path.segments.len() > 1 { + // For paths like crate::TestStruct, use just the type name + ident_str.clone() + } else { + ident_str.clone() + }; + + if known_schemas.contains_key(&type_name) { + // 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 + // 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 + // This prevents creating invalid references to non-existent schemas + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + } + } + } + Type::Reference(type_ref) => { + // Handle &T, &mut T, etc. + parse_type_to_schema_ref_with_schemas(&type_ref.elem, known_schemas, struct_definitions) + } + _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::collections::HashMap; + use vespera_core::schema::{SchemaRef, SchemaType}; + + #[rstest] + #[case("HashMap", Some(SchemaType::Object), true)] + #[case("Option", Some(SchemaType::String), false)] // nullable check + fn test_parse_type_to_schema_ref_cases( + #[case] ty_src: &str, + #[case] expected_type: Option, + #[case] expect_additional_props: bool, + ) { + let ty: syn::Type = syn::parse_str(ty_src).unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = schema_ref { + assert_eq!(schema.schema_type, expected_type); + if expect_additional_props { + assert!(schema.additional_properties.is_some()); + } + if ty_src.starts_with("Option") { + assert_eq!(schema.nullable, Some(true)); + } + } else { + panic!("Expected inline schema for {}", ty_src); + } + } + + #[test] + fn test_parse_type_to_schema_ref_generic_substitution() { + // Ensure generic struct Wrapper { value: T } is substituted to concrete type + let mut known_schemas = HashMap::new(); + known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string()); + + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "Wrapper".to_string(), + "struct Wrapper { value: T }".to_string(), + ); + + let ty: syn::Type = syn::parse_str("Wrapper").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions); + + if let SchemaRef::Inline(schema) = schema_ref { + let props = schema.properties.as_ref().unwrap(); + let value = props.get("value").unwrap(); + if let SchemaRef::Inline(inner) = value { + assert_eq!(inner.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema for value"); + } + } else { + panic!("Expected inline schema for generic substitution"); + } + } + + #[rstest] + // camelCase tests + #[case("user_name", Some("camelCase"), "userName")] + #[case("first_name", Some("camelCase"), "firstName")] + #[case("last_name", Some("camelCase"), "lastName")] + #[case("user_id", Some("camelCase"), "userId")] + #[case("api_key", Some("camelCase"), "apiKey")] + #[case("already_camel", Some("camelCase"), "alreadyCamel")] + // snake_case tests + #[case("userName", Some("snake_case"), "user_name")] + #[case("firstName", Some("snake_case"), "first_name")] + #[case("lastName", Some("snake_case"), "last_name")] + #[case("userId", Some("snake_case"), "user_id")] + #[case("apiKey", Some("snake_case"), "api_key")] + #[case("already_snake", Some("snake_case"), "already_snake")] + // kebab-case tests + #[case("user_name", Some("kebab-case"), "user-name")] + #[case("first_name", Some("kebab-case"), "first-name")] + #[case("last_name", Some("kebab-case"), "last-name")] + #[case("user_id", Some("kebab-case"), "user-id")] + #[case("api_key", Some("kebab-case"), "api-key")] + #[case("already-kebab", Some("kebab-case"), "already-kebab")] + // PascalCase tests + #[case("user_name", Some("PascalCase"), "UserName")] + #[case("first_name", Some("PascalCase"), "FirstName")] + #[case("last_name", Some("PascalCase"), "LastName")] + #[case("user_id", Some("PascalCase"), "UserId")] + #[case("api_key", Some("PascalCase"), "ApiKey")] + #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] + // lowercase tests + #[case("UserName", Some("lowercase"), "username")] + #[case("FIRST_NAME", Some("lowercase"), "first_name")] + #[case("lastName", Some("lowercase"), "lastname")] + #[case("User_ID", Some("lowercase"), "user_id")] + #[case("API_KEY", Some("lowercase"), "api_key")] + #[case("already_lower", Some("lowercase"), "already_lower")] + // UPPERCASE tests + #[case("user_name", Some("UPPERCASE"), "USER_NAME")] + #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] + #[case("LastName", Some("UPPERCASE"), "LASTNAME")] + #[case("user_id", Some("UPPERCASE"), "USER_ID")] + #[case("apiKey", Some("UPPERCASE"), "APIKEY")] + #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] + // SCREAMING_SNAKE_CASE tests + #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] + #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] + #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] + #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] + #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] + #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] + // SCREAMING-KEBAB-CASE tests + #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] + #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] + #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] + #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] + #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] + #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] + // None tests (no transformation) + #[case("user_name", None, "user_name")] + #[case("firstName", None, "firstName")] + #[case("LastName", None, "LastName")] + #[case("user-id", None, "user-id")] + fn test_rename_field( + #[case] field_name: &str, + #[case] rename_all: Option<&str>, + #[case] expected: &str, + ) { + assert_eq!(rename_field(field_name, rename_all), expected); + } +} + diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap new file mode 100644 index 0000000..5c66c3b --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "item_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap new file mode 100644 index 0000000..b812a89 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "user_agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap new file mode 100644 index 0000000..b812a89 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "user_agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap new file mode 100644 index 0000000..e7e5043 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "content-type", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap new file mode 100644 index 0000000..34281ed --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "authorization", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap new file mode 100644 index 0000000..b812a89 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "user_agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap new file mode 100644 index 0000000..7024513 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap @@ -0,0 +1,116 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: params +--- +[ + Parameter { + name: "user_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "count", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap new file mode 100644 index 0000000..dde72ea --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "text/plain": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap new file mode 100644 index 0000000..dde72ea --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "text/plain": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap new file mode 100644 index 0000000..313c95d --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +None diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap new file mode 100644 index 0000000..e7019a5 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "application/json": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 8817e3a..7cc7f54 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -14,6 +14,6 @@ serde_json = "1" vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "18.3" +axum-test = "18.4" insta = "1.44" diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 1515b9c..e4d39a8 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -829,6 +829,66 @@ } } }, + "/typed-header": { + "get": { + "operationId": "typed_header_jwt", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "operationId": "typed_header", + "parameters": [ + { + "name": "user-agent", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "content-type", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users": { "get": { "operationId": "get_users", diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index cb35618..81ef24c 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -15,6 +15,7 @@ pub mod generic; pub mod health; pub mod path; pub mod users; +pub mod typed_header; /// Health check endpoint #[vespera::route(get)] diff --git a/examples/axum-example/src/routes/typed_header.rs b/examples/axum-example/src/routes/typed_header.rs new file mode 100644 index 0000000..75a5d18 --- /dev/null +++ b/examples/axum-example/src/routes/typed_header.rs @@ -0,0 +1,20 @@ +use vespera::axum_extra::{ + TypedHeader, + headers::{Authorization, ContentType, UserAgent, authorization::Bearer}, + +}; + + +#[vespera::route(post)] +pub async fn typed_header(TypedHeader(user_agent): TypedHeader, content_type: Option>) -> &'static str { + println!("user_agent: {:?}", user_agent); + println!("content_type: {:?}", content_type); + "ok" +} + + +#[vespera::route()] +pub async fn typed_header_jwt(TypedHeader(authorization): TypedHeader>) -> &'static str { + println!("authorization: {:?}", authorization); + "ok" +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index f72f6fb..d4c598e 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -833,6 +833,66 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/typed-header": { + "get": { + "operationId": "typed_header_jwt", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "operationId": "typed_header", + "parameters": [ + { + "name": "user-agent", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "content-type", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users": { "get": { "operationId": "get_users", diff --git a/openapi.json b/openapi.json index 1515b9c..e4d39a8 100644 --- a/openapi.json +++ b/openapi.json @@ -829,6 +829,66 @@ } } }, + "/typed-header": { + "get": { + "operationId": "typed_header_jwt", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "operationId": "typed_header", + "parameters": [ + { + "name": "user-agent", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "content-type", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users": { "get": { "operationId": "get_users", From fff61b0a746be1b6464bc9733896a3333b446fd0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 00:49:07 +0900 Subject: [PATCH 08/30] Update snapshot --- ...er__parameters__tests__parse_function_parameter_cases-3.snap | 2 +- ...er__parameters__tests__parse_function_parameter_cases-4.snap | 2 +- ...er__parameters__tests__parse_function_parameter_cases-7.snap | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap index b812a89..7712f63 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap @@ -4,7 +4,7 @@ expression: params --- [ Parameter { - name: "user_agent", + name: "user-agent", in: Header, description: None, required: Some( diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap index b812a89..7712f63 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap @@ -4,7 +4,7 @@ expression: params --- [ Parameter { - name: "user_agent", + name: "user-agent", in: Header, description: None, required: Some( diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap index b812a89..7712f63 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap @@ -4,7 +4,7 @@ expression: params --- [ Parameter { - name: "user_agent", + name: "user-agent", in: Header, description: None, required: Some( From 6ed191f2cf1545fb0e2e43e8b6a0ce5122affaef Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 14:55:52 +0900 Subject: [PATCH 09/30] Fix snapshot --- crates/vespera_macro/src/parser/mod.rs | 1 - crates/vespera_macro/src/parser/operation.rs | 7 +- crates/vespera_macro/src/parser/parameters.rs | 13 +- crates/vespera_macro/src/parser/path.rs | 1 - .../vespera_macro/src/parser/request_body.rs | 5 +- crates/vespera_macro/src/parser/response.rs | 34 +++--- crates/vespera_macro/src/parser/schema.rs | 1 - ...sts__parse_function_parameter_cases-2.snap | 2 +- ...sts__parse_function_parameter_cases-3.snap | 60 +--------- ...sts__parse_function_parameter_cases-4.snap | 2 +- ...sts__parse_function_parameter_cases-5.snap | 112 +++++++++++++++++- ...sts__parse_function_parameter_cases-6.snap | 6 +- ...sts__parse_function_parameter_cases-7.snap | 61 ---------- ...tests__parse_function_parameter_cases.snap | 2 +- examples/axum-example/src/routes/mod.rs | 2 +- .../axum-example/src/routes/typed_header.rs | 12 +- 16 files changed, 155 insertions(+), 166 deletions(-) delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap diff --git a/crates/vespera_macro/src/parser/mod.rs b/crates/vespera_macro/src/parser/mod.rs index 9a638c7..3f27e44 100644 --- a/crates/vespera_macro/src/parser/mod.rs +++ b/crates/vespera_macro/src/parser/mod.rs @@ -7,4 +7,3 @@ mod schema; pub use operation::build_operation_from_function; pub use schema::{parse_enum_to_schema, parse_struct_to_schema}; - diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 08b16ca..52e1ad2 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -7,10 +7,8 @@ use vespera_core::{ }; use super::{ - parameters::parse_function_parameter, - path::extract_path_parameters, - request_body::parse_request_body, - response::parse_return_type, + parameters::parse_function_parameter, path::extract_path_parameters, + request_body::parse_request_body, response::parse_return_type, schema::parse_type_to_schema_ref_with_schemas, }; @@ -288,4 +286,3 @@ mod tests { assert!(op.parameters.is_none()); } } - diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index b6a20c6..ee20523 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -45,7 +45,7 @@ pub fn parse_function_parameter( if !path.segments.is_empty() { let segment = path.segments.first().unwrap(); let ident_str = segment.ident.to_string(); - + // Handle Option> if ident_str == "Option" { if let syn::PathArguments::AngleBracketed(args) = &segment.arguments @@ -55,7 +55,7 @@ pub fn parse_function_parameter( { let inner_segment = inner_type_path.path.segments.last().unwrap(); let inner_ident_str = inner_segment.ident.to_string(); - + if inner_ident_str == "TypedHeader" { // TypedHeader always uses string schema regardless of inner type return Some(vec![Parameter { @@ -436,6 +436,8 @@ mod tests { use rstest::rstest; use std::collections::HashMap; use vespera_core::route::ParameterLocation; + + use insta::assert_debug_snapshot; use serial_test::serial; #[rstest] #[case( @@ -485,9 +487,8 @@ mod tests { #[case] expected_locations: Vec>, ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + let mut parameters = Vec::new(); for (idx, arg) in func.sig.inputs.iter().enumerate() { - use insta::assert_debug_snapshot; - let result = parse_function_parameter(arg, &path_params, &HashMap::new(), &HashMap::new()); let expected = expected_locations @@ -510,8 +511,8 @@ mod tests { got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}" ); - assert_debug_snapshot!(params); + parameters.extend(params.clone()); } + assert_debug_snapshot!(parameters); } } - diff --git a/crates/vespera_macro/src/parser/path.rs b/crates/vespera_macro/src/parser/path.rs index 51413f5..f4f4ec1 100644 --- a/crates/vespera_macro/src/parser/path.rs +++ b/crates/vespera_macro/src/parser/path.rs @@ -30,4 +30,3 @@ mod tests { assert_eq!(extract_path_parameters(path), expected); } } - diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 8591aba..4e32618 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -90,11 +90,11 @@ pub fn parse_request_body( #[cfg(test)] mod tests { use super::*; + use insta::assert_debug_snapshot; use rstest::rstest; + use serial_test::serial; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; - use insta::assert_debug_snapshot; - use serial_test::serial; #[rstest] #[case::json("fn test(Json(payload): Json) {}", true)] @@ -129,4 +129,3 @@ mod tests { } } } - diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 2141b45..d8ddfa7 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -75,23 +75,24 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { if let Type::Tuple(tuple) = err_ty && tuple.elems.len() == 2 - && let Type::Path(type_path) = &tuple.elems[0]&& !&type_path.path.segments.is_empty() { - let path = &type_path.path; - 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())); - } + && let Type::Path(type_path) = &tuple.elems[0] + && !&type_path.path.segments.is_empty() + { + let path = &type_path.path; + 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 } @@ -545,4 +546,3 @@ mod tests { } } } - diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index a8c8d35..a38ed93 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -822,4 +822,3 @@ mod tests { assert_eq!(rename_field(field_name, rename_all), expected); } } - diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap index 5c66c3b..3c26d3e 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap @@ -1,6 +1,6 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- [ Parameter { diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap index 7712f63..bdfe0ad 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap @@ -1,61 +1,5 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- -[ - Parameter { - name: "user-agent", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap index 7712f63..4785bb1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap @@ -1,6 +1,6 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- [ Parameter { diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap index e7e5043..22e5ba6 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap @@ -1,8 +1,63 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- [ + Parameter { + name: "user-agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, Parameter { name: "content-type", in: Header, @@ -58,4 +113,59 @@ expression: params ), example: None, }, + Parameter { + name: "authorization", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap index 34281ed..4785bb1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap @@ -1,14 +1,14 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- [ Parameter { - name: "authorization", + name: "user-agent", in: Header, description: None, required: Some( - false, + true, ), schema: Some( Inline( diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap deleted file mode 100644 index 7712f63..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap +++ /dev/null @@ -1,61 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: params ---- -[ - Parameter { - name: "user-agent", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap index 7024513..93ae0c3 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap @@ -1,6 +1,6 @@ --- source: crates/vespera_macro/src/parser/parameters.rs -expression: params +expression: parameters --- [ Parameter { diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index 81ef24c..3b6c1b9 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -14,8 +14,8 @@ pub mod foo; pub mod generic; pub mod health; pub mod path; -pub mod users; pub mod typed_header; +pub mod users; /// Health check endpoint #[vespera::route(get)] diff --git a/examples/axum-example/src/routes/typed_header.rs b/examples/axum-example/src/routes/typed_header.rs index 75a5d18..78bfa20 100644 --- a/examples/axum-example/src/routes/typed_header.rs +++ b/examples/axum-example/src/routes/typed_header.rs @@ -1,20 +1,22 @@ use vespera::axum_extra::{ TypedHeader, headers::{Authorization, ContentType, UserAgent, authorization::Bearer}, - }; - #[vespera::route(post)] -pub async fn typed_header(TypedHeader(user_agent): TypedHeader, content_type: Option>) -> &'static str { +pub async fn typed_header( + TypedHeader(user_agent): TypedHeader, + content_type: Option>, +) -> &'static str { println!("user_agent: {:?}", user_agent); println!("content_type: {:?}", content_type); "ok" } - #[vespera::route()] -pub async fn typed_header_jwt(TypedHeader(authorization): TypedHeader>) -> &'static str { +pub async fn typed_header_jwt( + TypedHeader(authorization): TypedHeader>, +) -> &'static str { println!("authorization: {:?}", authorization); "ok" } From 0020d4648c292956cf4daf08aa2e27e3e30f31ea Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 15:09:24 +0900 Subject: [PATCH 10/30] Fix snapshot --- crates/vespera_macro/src/parser/parameters.rs | 279 ++++++++++++++++-- ...ts__parse_function_parameter_cases-10.snap | 61 ++++ ...parse_function_parameter_cases-10.snap.new | 119 ++++++++ ...ts__parse_function_parameter_cases-11.snap | 118 ++++++++ ...parse_function_parameter_cases-11.snap.new | 62 ++++ ...ts__parse_function_parameter_cases-12.snap | 61 ++++ ...parse_function_parameter_cases-12.snap.new | 62 ++++ ...ts__parse_function_parameter_cases-13.snap | 5 + ...parse_function_parameter_cases-13.snap.new | 62 ++++ ...ts__parse_function_parameter_cases-14.snap | 61 ++++ ...parse_function_parameter_cases-14.snap.new | 6 + ...ts__parse_function_parameter_cases-15.snap | 5 + ...parse_function_parameter_cases-15.snap.new | 62 ++++ ...ts__parse_function_parameter_cases-16.snap | 5 + ...ts__parse_function_parameter_cases-17.snap | 106 +++++++ ...parse_function_parameter_cases-17.snap.new | 6 + ...ts__parse_function_parameter_cases-18.snap | 63 ++++ ...parse_function_parameter_cases-18.snap.new | 107 +++++++ ...ts__parse_function_parameter_cases-19.snap | 61 ++++ ...parse_function_parameter_cases-19.snap.new | 64 ++++ ...sts__parse_function_parameter_cases-2.snap | 6 +- ..._parse_function_parameter_cases-2.snap.new | 62 ++++ ...sts__parse_function_parameter_cases-4.snap | 59 +++- ..._parse_function_parameter_cases-4.snap.new | 6 + ...sts__parse_function_parameter_cases-5.snap | 112 +------ ..._parse_function_parameter_cases-5.snap.new | 172 +++++++++++ ..._parse_function_parameter_cases-6.snap.new | 62 ++++ ...sts__parse_function_parameter_cases-7.snap | 5 + ..._parse_function_parameter_cases-7.snap.new | 62 ++++ ...sts__parse_function_parameter_cases-8.snap | 5 + ...sts__parse_function_parameter_cases-9.snap | 5 + ...tests__parse_function_parameter_cases.snap | 67 ++++- ...s__parse_function_parameter_cases.snap.new | 117 ++++++++ 33 files changed, 1975 insertions(+), 140 deletions(-) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index ee20523..3c6e22c 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -437,8 +437,39 @@ mod tests { use std::collections::HashMap; use vespera_core::route::ParameterLocation; - use insta::assert_debug_snapshot; - use serial_test::serial; + fn setup_test_data(func_src: &str) -> (HashMap, HashMap) { + let mut struct_definitions = HashMap::new(); + let known_schemas = HashMap::new(); + + if func_src.contains("QueryParams") { + struct_definitions.insert( + "QueryParams".to_string(), + r#" + pub struct QueryParams { + pub page: i32, + pub limit: Option, + } + "# + .to_string(), + ); + } + + if func_src.contains("User") { + struct_definitions.insert( + "User".to_string(), + r#" + pub struct User { + pub id: i32, + pub name: String, + } + "# + .to_string(), + ); + } + + (known_schemas, struct_definitions) + } + #[rstest] #[case( "fn test(params: Path<(String, i32)>) {}", @@ -447,50 +478,122 @@ mod tests { )] #[case( "fn show(Path(id): Path) {}", - vec!["item_id".to_string()], // path string name differs from pattern - vec![vec![ParameterLocation::Path]] // expect path param captured + vec!["item_id".to_string()], + vec![vec![ParameterLocation::Path]] )] #[case( "fn test(Query(params): Query>) {}", vec![], - vec![vec![]] // Query> is ignored + vec![vec![]] )] #[case( "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", vec![], vec![ - vec![ParameterLocation::Header], // first arg (TypedHeader) - vec![], // second arg (primitive, ignored) + vec![ParameterLocation::Header], + vec![], ] )] #[case( "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", vec![], vec![ - vec![ParameterLocation::Header], // first arg (TypedHeader) - vec![ParameterLocation::Header], // second arg (TypedHeader) - vec![ParameterLocation::Header], // third arg (TypedHeader) + vec![ParameterLocation::Header], + vec![ParameterLocation::Header], + vec![ParameterLocation::Header], ] )] #[case( "fn test(user_agent: TypedHeader, count: i32) {}", vec![], vec![ - vec![ParameterLocation::Header], // first arg (TypedHeader) - vec![], // second arg (primitive, ignored) + vec![ParameterLocation::Header], + vec![], ] )] - #[serial] + #[case( + "fn test(&self, id: i32) {}", + vec![], + vec![ + vec![], + vec![], + ] + )] + #[case( + "fn test(Path((a, b)): Path<(i32, String)>) {}", + vec![], + vec![vec![]] + )] + #[case( + "fn test(Path([a]): Path<[i32; 1]>) {}", + vec![], + vec![vec![]] + )] + #[case( + "fn test(id: Path) {}", + vec!["user_id".to_string(), "post_id".to_string()], + vec![vec![ParameterLocation::Path]] + )] + #[case( + "fn test(params: Query) {}", + vec![], + vec![vec![ParameterLocation::Query, ParameterLocation::Query]] + )] + #[case( + "fn test(id: Query) {}", + vec![], + vec![vec![ParameterLocation::Query]] + )] + #[case( + "fn test(auth: Header) {}", + vec![], + vec![vec![ParameterLocation::Header]] + )] + #[case( + "fn test(body: Json) {}", + vec![], + vec![vec![]] + )] + #[case( + "fn test(id: i32) {}", + vec!["id".to_string()], + vec![vec![ParameterLocation::Path]] + )] + #[case( + "fn test(params: Query) {}", + vec![], + vec![vec![]] + )] + #[case( + "fn test(params: Query>) {}", + vec![], + vec![vec![]] + )] + #[case( + "fn test(params: Query>) {}", + vec![], + vec![vec![ParameterLocation::Query]] + )] + #[case( + "fn test(params: Query>) {}", + vec![], + vec![vec![ParameterLocation::Query]] + )] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); - let mut parameters = Vec::new(); + let (known_schemas, struct_definitions) = setup_test_data(func_src); + for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = - parse_function_parameter(arg, &path_params, &HashMap::new(), &HashMap::new()); + let result = parse_function_parameter( + arg, + &path_params, + &known_schemas, + &struct_definitions, + ); let expected = expected_locations .get(idx) .unwrap_or_else(|| expected_locations.last().unwrap()); @@ -511,8 +614,148 @@ mod tests { got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}" ); - parameters.extend(params.clone()); } - assert_debug_snapshot!(parameters); + } + + #[test] + fn test_is_map_type() { + // Test HashMap + let ty: Type = syn::parse_str("HashMap").unwrap(); + assert!(is_map_type(&ty)); + + // Test BTreeMap + let ty: Type = syn::parse_str("BTreeMap").unwrap(); + assert!(is_map_type(&ty)); + + // Test non-map type (should return false) + let ty: Type = syn::parse_str("String").unwrap(); + assert!(!is_map_type(&ty)); + + // Test Type::Path with empty segments (should return false) + let ty: Type = syn::parse_str("Vec").unwrap(); + assert!(!is_map_type(&ty)); + } + + #[test] + fn test_is_known_type() { + let mut known_schemas = HashMap::new(); + let mut struct_definitions = HashMap::new(); + + // Test primitive type + let ty: Type = syn::parse_str("i32").unwrap(); + assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test known struct + struct_definitions.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); + let ty: Type = syn::parse_str("User").unwrap(); + assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test known schema + known_schemas.insert("Product".to_string(), "Product".to_string()); + let ty: Type = syn::parse_str("Product").unwrap(); + assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test Vec with known inner type + let ty: Type = syn::parse_str("Vec").unwrap(); + assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test Option with known inner type + let ty: Type = syn::parse_str("Option").unwrap(); + assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test unknown type + let ty: Type = syn::parse_str("UnknownType").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Test Type::Path with empty segments + // This is hard to create syntactically, but the code path exists + } + + #[test] + fn test_parse_query_struct_to_parameters() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashMap::new(); + + // Test with struct that has fields + struct_definitions.insert( + "QueryParams".to_string(), + r#" + #[serde(rename_all = "camelCase")] + pub struct QueryParams { + pub page: i32, + #[serde(rename = "per_page")] + pub limit: Option, + pub search: String, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("QueryParams").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.len(), 3); + assert_eq!(params[0].name, "page"); + assert_eq!(params[0].r#in, ParameterLocation::Query); + assert_eq!(params[1].name, "per_page"); + assert_eq!(params[1].r#in, ParameterLocation::Query); + assert_eq!(params[2].name, "search"); + assert_eq!(params[2].r#in, ParameterLocation::Query); + + // Test with struct that has nested struct (ref to inline conversion) + struct_definitions.insert( + "NestedQuery".to_string(), + r#" + pub struct NestedQuery { + pub user: User, + } + "# + .to_string(), + ); + struct_definitions.insert( + "User".to_string(), + r#" + pub struct User { + pub id: i32, + } + "# + .to_string(), + ); + known_schemas.insert("User".to_string(), "#/components/schemas/User".to_string()); + + let ty: Type = syn::parse_str("NestedQuery").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + assert!(result.is_some()); + + // Test with non-struct type + let ty: Type = syn::parse_str("i32").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + assert!(result.is_none()); + + // Test with unknown struct + let ty: Type = syn::parse_str("UnknownStruct").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + assert!(result.is_none()); + + // Test with struct that has Option fields + struct_definitions.insert( + "OptionalQuery".to_string(), + r#" + pub struct OptionalQuery { + pub required: i32, + pub optional: Option, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("OptionalQuery").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.len(), 2); + assert_eq!(params[0].required, Some(true)); + assert_eq!(params[1].required, Some(false)); } } diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap new file mode 100644 index 0000000..462cdeb --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new new file mode 100644 index 0000000..b103d60 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new @@ -0,0 +1,119 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "page", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "limit", + in: Query, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap new file mode 100644 index 0000000..222c7cb --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap @@ -0,0 +1,118 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "page", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "limit", + in: Query, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new new file mode 100644 index 0000000..306a0a1 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "id", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap new file mode 100644 index 0000000..3c26d3e --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "item_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new new file mode 100644 index 0000000..533f878 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new new file mode 100644 index 0000000..6ae91c9 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "user-agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap new file mode 100644 index 0000000..462cdeb --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new new file mode 100644 index 0000000..5dc679f --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new @@ -0,0 +1,6 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new new file mode 100644 index 0000000..533f878 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap new file mode 100644 index 0000000..c4081fb --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap @@ -0,0 +1,106 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "params", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Array, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new new file mode 100644 index 0000000..5dc679f --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new @@ -0,0 +1,6 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap new file mode 100644 index 0000000..0dfec8a --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap @@ -0,0 +1,63 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "params", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new new file mode 100644 index 0000000..51a8030 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new @@ -0,0 +1,107 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "params", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Array, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap new file mode 100644 index 0000000..19239ed --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "id", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new new file mode 100644 index 0000000..eaab137 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "params", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap index 3c26d3e..4785bb1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "item_id", - in: Path, + name: "user-agent", + in: Header, description: None, required: Some( true, @@ -15,7 +15,7 @@ expression: parameters Schema { ref_path: None, schema_type: Some( - Integer, + String, ), format: None, title: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new new file mode 100644 index 0000000..1084173 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "item_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap index 4785bb1..93ae0c3 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "user-agent", - in: Header, + name: "user_id", + in: Path, description: None, required: Some( true, @@ -58,4 +58,59 @@ expression: parameters ), example: None, }, + Parameter { + name: "count", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new new file mode 100644 index 0000000..5dc679f --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new @@ -0,0 +1,6 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap index 22e5ba6..bbd117b 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap @@ -4,7 +4,7 @@ expression: parameters --- [ Parameter { - name: "user-agent", + name: "auth", in: Header, description: None, required: Some( @@ -58,114 +58,4 @@ expression: parameters ), example: None, }, - Parameter { - name: "content-type", - in: Header, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "authorization", - in: Header, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new new file mode 100644 index 0000000..6055199 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new @@ -0,0 +1,172 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "user-agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "content-type", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "authorization", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new new file mode 100644 index 0000000..f3ffe3d --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "auth", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new new file mode 100644 index 0000000..6ae91c9 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new @@ -0,0 +1,62 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "user-agent", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap new file mode 100644 index 0000000..bdfe0ad --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap index 93ae0c3..22e5ba6 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "user_id", - in: Path, + name: "user-agent", + in: Header, description: None, required: Some( true, @@ -59,18 +59,73 @@ expression: parameters example: None, }, Parameter { - name: "count", - in: Path, + name: "content-type", + in: Header, description: None, required: Some( - true, + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "authorization", + in: Header, + description: None, + required: Some( + false, ), schema: Some( Inline( Schema { ref_path: None, schema_type: Some( - Integer, + String, ), format: None, title: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new new file mode 100644 index 0000000..ec0fcc4 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new @@ -0,0 +1,117 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +assertion_line: 619 +expression: parameters +--- +[ + Parameter { + name: "user_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "count", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] From f69b0e45813ba421831e9ee093cf491f996676ca Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 15:55:22 +0900 Subject: [PATCH 11/30] Add testcase --- crates/vespera_macro/src/parser/parameters.rs | 105 +++++------ ...ts__parse_function_parameter_cases-10.snap | 58 +----- ...parse_function_parameter_cases-10.snap.new | 119 ------------ ...ts__parse_function_parameter_cases-11.snap | 61 +------ ...parse_function_parameter_cases-11.snap.new | 62 ------- ...ts__parse_function_parameter_cases-12.snap | 58 +----- ...parse_function_parameter_cases-12.snap.new | 62 ------- ...parse_function_parameter_cases-13.snap.new | 62 ------- ...ts__parse_function_parameter_cases-14.snap | 61 ------- ...parse_function_parameter_cases-14.snap.new | 6 - ...ts__parse_function_parameter_cases-15.snap | 5 - ...parse_function_parameter_cases-15.snap.new | 62 ------- ...ts__parse_function_parameter_cases-16.snap | 5 - ...ts__parse_function_parameter_cases-17.snap | 106 ----------- ...parse_function_parameter_cases-17.snap.new | 6 - ...ts__parse_function_parameter_cases-18.snap | 63 ------- ...parse_function_parameter_cases-18.snap.new | 107 ----------- ...ts__parse_function_parameter_cases-19.snap | 61 ------- ...parse_function_parameter_cases-19.snap.new | 64 ------- ...sts__parse_function_parameter_cases-2.snap | 6 +- ..._parse_function_parameter_cases-2.snap.new | 62 ------- ...sts__parse_function_parameter_cases-4.snap | 59 +----- ..._parse_function_parameter_cases-4.snap.new | 6 - ...sts__parse_function_parameter_cases-5.snap | 112 +++++++++++- ..._parse_function_parameter_cases-5.snap.new | 172 ------------------ ..._parse_function_parameter_cases-6.snap.new | 62 ------- ..._parse_function_parameter_cases-7.snap.new | 62 ------- ...sts__parse_function_parameter_cases-9.snap | 115 +++++++++++- ...tests__parse_function_parameter_cases.snap | 67 +------ ...s__parse_function_parameter_cases.snap.new | 117 ------------ 30 files changed, 282 insertions(+), 1691 deletions(-) delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new delete mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 3c6e22c..025f53f 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -436,6 +436,7 @@ mod tests { use rstest::rstest; use std::collections::HashMap; use vespera_core::route::ParameterLocation; + use insta::assert_debug_snapshot; fn setup_test_data(func_src: &str) -> (HashMap, HashMap) { let mut struct_definitions = HashMap::new(); @@ -524,31 +525,11 @@ mod tests { vec![], vec![vec![]] )] - #[case( - "fn test(Path([a]): Path<[i32; 1]>) {}", - vec![], - vec![vec![]] - )] - #[case( - "fn test(id: Path) {}", - vec!["user_id".to_string(), "post_id".to_string()], - vec![vec![ParameterLocation::Path]] - )] #[case( "fn test(params: Query) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]] )] - #[case( - "fn test(id: Query) {}", - vec![], - vec![vec![ParameterLocation::Query]] - )] - #[case( - "fn test(auth: Header) {}", - vec![], - vec![vec![ParameterLocation::Header]] - )] #[case( "fn test(body: Json) {}", vec![], @@ -569,16 +550,6 @@ mod tests { vec![], vec![vec![]] )] - #[case( - "fn test(params: Query>) {}", - vec![], - vec![vec![ParameterLocation::Query]] - )] - #[case( - "fn test(params: Query>) {}", - vec![], - vec![vec![ParameterLocation::Query]] - )] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, @@ -586,6 +557,7 @@ mod tests { ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); + let mut parameters = Vec::new(); for (idx, arg) in func.sig.inputs.iter().enumerate() { let result = parse_function_parameter( @@ -614,7 +586,9 @@ mod tests { got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}" ); + parameters.extend(params.clone()); } + assert_debug_snapshot!(parameters); } #[test] @@ -636,39 +610,44 @@ mod tests { assert!(!is_map_type(&ty)); } - #[test] - fn test_is_known_type() { - let mut known_schemas = HashMap::new(); - let mut struct_definitions = HashMap::new(); - - // Test primitive type - let ty: Type = syn::parse_str("i32").unwrap(); - assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test known struct - struct_definitions.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); - let ty: Type = syn::parse_str("User").unwrap(); - assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test known schema - known_schemas.insert("Product".to_string(), "Product".to_string()); - let ty: Type = syn::parse_str("Product").unwrap(); - assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test Vec with known inner type - let ty: Type = syn::parse_str("Vec").unwrap(); - assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test Option with known inner type - let ty: Type = syn::parse_str("Option").unwrap(); - assert!(is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test unknown type - let ty: Type = syn::parse_str("UnknownType").unwrap(); - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Test Type::Path with empty segments - // This is hard to create syntactically, but the code path exists + #[rstest] + #[case("i32", HashMap::new(), HashMap::new(), true)] // primitive type + #[case( + "User", + HashMap::new(), + { + let mut map = HashMap::new(); + map.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); + map + }, + true + )] // known struct + #[case( + "Product", + { + let mut map = HashMap::new(); + map.insert("Product".to_string(), "Product".to_string()); + map + }, + HashMap::new(), + true + )] // known schema + #[case("Vec", HashMap::new(), HashMap::new(), true)] // Vec with known inner type + #[case("Option", HashMap::new(), HashMap::new(), true)] // Option with known inner type + #[case("UnknownType", HashMap::new(), HashMap::new(), false)] // unknown type + fn test_is_known_type( + #[case] type_str: &str, + #[case] known_schemas: HashMap, + #[case] struct_definitions: HashMap, + #[case] expected: bool, + ) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!( + is_known_type(&ty, &known_schemas, &struct_definitions), + expected, + "Type: {}", + type_str + ); } #[test] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap index 462cdeb..bdfe0ad 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap @@ -2,60 +2,4 @@ source: crates/vespera_macro/src/parser/parameters.rs expression: parameters --- -[ - Parameter { - name: "id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new deleted file mode 100644 index b103d60..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap.new +++ /dev/null @@ -1,119 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "page", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "limit", - in: Query, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap index 222c7cb..462cdeb 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "page", - in: Query, + name: "id", + in: Path, description: None, required: Some( true, @@ -58,61 +58,4 @@ expression: parameters ), example: None, }, - Parameter { - name: "limit", - in: Query, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new deleted file mode 100644 index 306a0a1..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "id", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap index 3c26d3e..bdfe0ad 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap @@ -2,60 +2,4 @@ source: crates/vespera_macro/src/parser/parameters.rs expression: parameters --- -[ - Parameter { - name: "item_id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new deleted file mode 100644 index 533f878..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new deleted file mode 100644 index 6ae91c9..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "user-agent", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap deleted file mode 100644 index 462cdeb..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap +++ /dev/null @@ -1,61 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[ - Parameter { - name: "id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new deleted file mode 100644 index 5dc679f..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap.new +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap deleted file mode 100644 index bdfe0ad..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new deleted file mode 100644 index 533f878..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-15.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap deleted file mode 100644 index bdfe0ad..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-16.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap deleted file mode 100644 index c4081fb..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap +++ /dev/null @@ -1,106 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[ - Parameter { - name: "params", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Array, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new deleted file mode 100644 index 5dc679f..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-17.snap.new +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap deleted file mode 100644 index 0dfec8a..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[ - Parameter { - name: "params", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new deleted file mode 100644 index 51a8030..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-18.snap.new +++ /dev/null @@ -1,107 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "params", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Array, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap deleted file mode 100644 index 19239ed..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap +++ /dev/null @@ -1,61 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -expression: parameters ---- -[ - Parameter { - name: "id", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new deleted file mode 100644 index eaab137..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-19.snap.new +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "params", - in: Query, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap index 4785bb1..3c26d3e 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "user-agent", - in: Header, + name: "item_id", + in: Path, description: None, required: Some( true, @@ -15,7 +15,7 @@ expression: parameters Schema { ref_path: None, schema_type: Some( - String, + Integer, ), format: None, title: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new deleted file mode 100644 index 1084173..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "item_id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap index 93ae0c3..4785bb1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "user_id", - in: Path, + name: "user-agent", + in: Header, description: None, required: Some( true, @@ -58,59 +58,4 @@ expression: parameters ), example: None, }, - Parameter { - name: "count", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new deleted file mode 100644 index 5dc679f..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap.new +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap index bbd117b..22e5ba6 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap @@ -4,7 +4,7 @@ expression: parameters --- [ Parameter { - name: "auth", + name: "user-agent", in: Header, description: None, required: Some( @@ -58,4 +58,114 @@ expression: parameters ), example: None, }, + Parameter { + name: "content-type", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "authorization", + in: Header, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, ] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new deleted file mode 100644 index 6055199..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap.new +++ /dev/null @@ -1,172 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "user-agent", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "content-type", - in: Header, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "authorization", - in: Header, - description: None, - required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new deleted file mode 100644 index f3ffe3d..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "auth", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new deleted file mode 100644 index 6ae91c9..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap.new +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "user-agent", - in: Header, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap index bdfe0ad..222c7cb 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap @@ -2,4 +2,117 @@ source: crates/vespera_macro/src/parser/parameters.rs expression: parameters --- -[] +[ + Parameter { + name: "page", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "limit", + in: Query, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap index 22e5ba6..93ae0c3 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap @@ -4,8 +4,8 @@ expression: parameters --- [ Parameter { - name: "user-agent", - in: Header, + name: "user_id", + in: Path, description: None, required: Some( true, @@ -59,73 +59,18 @@ expression: parameters example: None, }, Parameter { - name: "content-type", - in: Header, + name: "count", + in: Path, description: None, required: Some( - false, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "authorization", - in: Header, - description: None, - required: Some( - false, + true, ), schema: Some( Inline( Schema { ref_path: None, schema_type: Some( - String, + Integer, ), format: None, title: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new deleted file mode 100644 index ec0fcc4..0000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap.new +++ /dev/null @@ -1,117 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/parameters.rs -assertion_line: 619 -expression: parameters ---- -[ - Parameter { - name: "user_id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, - Parameter { - name: "count", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] From d48a923e4353381c61c948a29c2b65f69289574c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 16:23:34 +0900 Subject: [PATCH 12/30] Add wrong testcase --- crates/vespera_macro/src/parser/parameters.rs | 89 +++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 025f53f..077b2c7 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -119,13 +119,11 @@ pub fn parse_function_parameter( } } else { // Single path parameter - // If there's exactly one path parameter, use its name - let name = if path_params.len() == 1 { - path_params[0].clone() - } else { - // Otherwise use the parameter name from the pattern - param_name - }; + // Allow only when exactly one path parameter is provided + if path_params.len() != 1 { + return None; + } + let name = path_params[0].clone(); return Some(vec![Parameter { name, r#in: ParameterLocation::Path, @@ -161,6 +159,11 @@ pub fn parse_function_parameter( return Some(struct_params); } + // Ignore primitive-like query params (including Vec/Option of primitive) + if is_primitive_like(inner_ty) { + return None; + } + // 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) { @@ -188,6 +191,10 @@ pub fn parse_function_parameter( && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + // Ignore primitive-like headers + if is_primitive_like(inner_ty) { + return None; + } return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Header, @@ -257,6 +264,25 @@ fn is_map_type(ty: &Type) -> bool { false } +fn is_primitive_like(ty: &Type) -> bool { + if is_primitive_type(ty) { + return true; + } + if let Type::Path(type_path) = ty { + if let Some(seg) = type_path.path.segments.last() { + let ident = seg.ident.to_string(); + if let syn::PathArguments::AngleBracketed(args) = &seg.arguments { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + if (ident == "Vec" || ident == "Option") && is_primitive_like(inner_ty) { + return true; + } + } + } + } + } + false +} + fn is_known_type( ty: &Type, known_schemas: &HashMap, @@ -591,6 +617,55 @@ mod tests { assert_debug_snapshot!(parameters); } + #[rstest] + #[case( + "fn test(id: Query) {}", + vec![], + )] + #[case( + "fn test(auth: Header) {}", + vec![], + )] + #[case( + "fn test(params: Query>) {}", + vec![], + )] + #[case( + "fn test(params: Query>) {}", + vec![], + )] + #[case( + "fn test(Path([a]): Path<[i32; 1]>) {}", + vec![], + )] + #[case( + "fn test(id: Path) {}", + vec!["user_id".to_string(), "post_id".to_string()], + )] + fn test_parse_function_parameter_wrong_cases( + #[case] func_src: &str, + #[case] path_params: Vec, + ) { + let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); + let (known_schemas, struct_definitions) = setup_test_data(func_src); + + for (idx, arg) in func.sig.inputs.iter().enumerate() { + let result = parse_function_parameter( + arg, + &path_params, + &known_schemas, + &struct_definitions, + ); + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}, got: {:?}", + idx, + func_src, + result + ); + } + } + #[test] fn test_is_map_type() { // Test HashMap From 2cead44222ac6c6b7759e25bfbad5714491113c8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 16:53:42 +0900 Subject: [PATCH 13/30] Add testcase --- crates/vespera_macro/src/parser/parameters.rs | 65 ++++++---- ...ts__parse_function_parameter_cases-11.snap | 58 +-------- ...ts__parse_function_parameter_cases-13.snap | 113 +++++++++++++++++- ...ts__parse_function_parameter_cases-14.snap | 61 ++++++++++ 4 files changed, 216 insertions(+), 81 deletions(-) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 077b2c7..e68810d 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -466,7 +466,7 @@ mod tests { fn setup_test_data(func_src: &str) -> (HashMap, HashMap) { let mut struct_definitions = HashMap::new(); - let known_schemas = HashMap::new(); + let known_schemas: HashMap = HashMap::new(); if func_src.contains("QueryParams") { struct_definitions.insert( @@ -561,11 +561,6 @@ mod tests { vec![], vec![vec![]] )] - #[case( - "fn test(id: i32) {}", - vec!["id".to_string()], - vec![vec![ParameterLocation::Path]] - )] #[case( "fn test(params: Query) {}", vec![], @@ -576,6 +571,16 @@ mod tests { vec![], vec![vec![]] )] + #[case( + "fn test(user: Query) {}", + vec![], + vec![vec![ParameterLocation::Query, ParameterLocation::Query]] + )] + #[case( + "fn test(custom: Header) {}", + vec![], + vec![vec![ParameterLocation::Header]] + )] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, @@ -642,6 +647,10 @@ mod tests { "fn test(id: Path) {}", vec!["user_id".to_string(), "post_id".to_string()], )] + #[case( + "fn test((x, y): (i32, i32)) {}", + vec![], + )] fn test_parse_function_parameter_wrong_cases( #[case] func_src: &str, #[case] path_params: Vec, @@ -649,6 +658,15 @@ mod tests { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); + // Provide custom types for header/query known schemas/structs + let mut struct_definitions = struct_definitions; + struct_definitions.insert( + "User".to_string(), + "pub struct User { pub id: i32 }".to_string(), + ); + let mut known_schemas = known_schemas; + known_schemas.insert("CustomHeader".to_string(), "#/components/schemas/CustomHeader".to_string()); + for (idx, arg) in func.sig.inputs.iter().enumerate() { let result = parse_function_parameter( arg, @@ -666,23 +684,24 @@ mod tests { } } - #[test] - fn test_is_map_type() { - // Test HashMap - let ty: Type = syn::parse_str("HashMap").unwrap(); - assert!(is_map_type(&ty)); - - // Test BTreeMap - let ty: Type = syn::parse_str("BTreeMap").unwrap(); - assert!(is_map_type(&ty)); - - // Test non-map type (should return false) - let ty: Type = syn::parse_str("String").unwrap(); - assert!(!is_map_type(&ty)); - - // Test Type::Path with empty segments (should return false) - let ty: Type = syn::parse_str("Vec").unwrap(); - assert!(!is_map_type(&ty)); + #[rstest] + #[case("i32", true)] + #[case("Vec", true)] + #[case("Option", true)] + #[case("CustomType", false)] + fn test_is_primitive_like_fn(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(is_primitive_like(&ty), expected, "type_str={}", type_str); + } + + #[rstest] + #[case("HashMap", true)] + #[case("BTreeMap", true)] + #[case("String", false)] + #[case("Vec", false)] + fn test_is_map_type(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(is_map_type(&ty), expected, "type_str={}", type_str); } #[rstest] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap index 462cdeb..bdfe0ad 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap @@ -2,60 +2,4 @@ source: crates/vespera_macro/src/parser/parameters.rs expression: parameters --- -[ - Parameter { - name: "id", - in: Path, - description: None, - required: Some( - true, - ), - schema: Some( - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ), - example: None, - }, -] +[] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap index bdfe0ad..b48f614 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap @@ -2,4 +2,115 @@ source: crates/vespera_macro/src/parser/parameters.rs expression: parameters --- -[] +[ + Parameter { + name: "id", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "name", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap new file mode 100644 index 0000000..8940011 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "custom", + in: Header, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] From 6e67932cfc1d28605d08345bdc6f0c0535dc8d73 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 17:11:05 +0900 Subject: [PATCH 14/30] Add testcase --- crates/vespera_macro/src/parser/schema.rs | 170 +++++++++++++ ..._to_schema_tuple_and_named_variants-2.snap | 237 +++++++++++++++++ ..._to_schema_tuple_and_named_variants-3.snap | 239 ++++++++++++++++++ ...um_to_schema_tuple_and_named_variants.snap | 142 +++++++++++ ...s__parse_enum_to_schema_unit_variants.snap | 51 ++++ 5 files changed, 839 insertions(+) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index a38ed93..89f5e28 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -700,6 +700,7 @@ mod tests { use rstest::rstest; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; + use insta::assert_debug_snapshot; #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] @@ -724,6 +725,175 @@ mod tests { } } + #[rstest] + #[case( + r#" + #[serde(rename_all = "kebab-case")] + enum Status { + #[serde(rename = "ok-status")] + Ok, + ErrorCode, + } + "#, + SchemaType::String, + vec!["ok-status", "ErrorCode"] // rename_all is not applied in this path + )] + fn test_parse_enum_to_schema_unit_variants( + #[case] enum_src: &str, + #[case] expected_type: SchemaType, + #[case] expected_enum: Vec<&str>, + ) { + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + assert_eq!(schema.schema_type, Some(expected_type)); + let got = schema.clone() + .r#enum + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(got, expected_enum); + assert_debug_snapshot!(schema); + } + + #[rstest] + #[case( + r#" + enum Event { + Data(String), + } + "#, + 1, + Some(SchemaType::String), + 0 // single-field tuple variant stored as object with inline schema + )] + #[case( + r#" + enum Pair { + Values(i32, String), + } + "#, + 1, + Some(SchemaType::Array), + 2 // tuple array prefix_items length + )] + #[case( + r#" + enum Msg { + Detail { id: i32, note: Option }, + } + "#, + 1, + Some(SchemaType::Object), + 0 // not an array; ignore prefix_items length + )] + fn test_parse_enum_to_schema_tuple_and_named_variants( + #[case] enum_src: &str, + #[case] expected_one_of_len: usize, + #[case] expected_inner_type: Option, + #[case] expected_prefix_items_len: usize, + ) { + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), expected_one_of_len); + + if let Some(inner_expected) = expected_inner_type { + if let SchemaRef::Inline(obj) = &one_of[0] { + let props = obj.properties.as_ref().expect("props missing"); + // take first property value + let inner_schema = props.values().next().expect("no property value"); + match inner_expected { + SchemaType::Array => { + if let SchemaRef::Inline(array_schema) = inner_schema { + assert_eq!(array_schema.schema_type, Some(SchemaType::Array)); + if expected_prefix_items_len > 0 { + assert_eq!( + array_schema.prefix_items.as_ref().unwrap().len(), + expected_prefix_items_len + ); + } + } else { + panic!("Expected inline array schema"); + } + } + SchemaType::Object => { + if let SchemaRef::Inline(inner_obj) = inner_schema { + assert_eq!(inner_obj.schema_type, Some(SchemaType::Object)); + let inner_props = inner_obj.properties.as_ref().unwrap(); + assert!(inner_props.contains_key("id")); + assert!(inner_props.contains_key("note")); + assert!(inner_obj.required.as_ref().unwrap().contains(&"id".to_string())); + } else { + panic!("Expected inline object schema"); + } + } + _ => {} + } + } else { + panic!("Expected inline schema in one_of"); + } + } + + assert_debug_snapshot!(schema); + } + + #[test] + fn test_parse_struct_to_schema_required_optional() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + struct User { + id: i32, + name: Option, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + assert!(schema.required.as_ref().unwrap().contains(&"id".to_string())); + assert!(!schema.required.as_ref().unwrap().contains(&"name".to_string())); + } + + #[test] + fn test_parse_type_to_schema_ref_empty_path_and_reference() { + // Empty path segments returns object + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + + // Reference type delegates to inner + let ty: Type = syn::parse_str("&i32").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer schema"); + } + } + + #[test] + fn test_parse_type_to_schema_ref_known_schema_ref_and_unknown_custom() { + let mut known_schemas = HashMap::new(); + known_schemas.insert("Known".to_string(), "Known".to_string()); + + let ty: Type = syn::parse_str("Known").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Ref(_))); + + let ty: Type = syn::parse_str("UnknownType").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + #[test] fn test_parse_type_to_schema_ref_generic_substitution() { // Ensure generic struct Wrapper { value: T } is substituted to concrete type diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap new file mode 100644 index 0000000..7c87eba --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap @@ -0,0 +1,237 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: None, + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: Some( + [ + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: Some( + { + "Values": Inline( + Schema { + ref_path: None, + schema_type: Some( + Array, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: Some( + [ + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ], + ), + min_items: Some( + 2, + ), + max_items: Some( + 2, + ), + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + }, + ), + required: Some( + [ + "Values", + ], + ), + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ], + ), + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap new file mode 100644 index 0000000..77851e7 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap @@ -0,0 +1,239 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: None, + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: Some( + [ + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: Some( + { + "Detail": Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: Some( + { + "id": Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + "note": Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + }, + ), + required: Some( + [ + "id", + ], + ), + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + }, + ), + required: Some( + [ + "Detail", + ], + ), + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ], + ), + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap new file mode 100644 index 0000000..16038cc --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap @@ -0,0 +1,142 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: None, + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: Some( + [ + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: Some( + { + "Data": Inline( + Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + }, + ), + required: Some( + [ + "Data", + ], + ), + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ], + ), + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap new file mode 100644 index 0000000..ddef11c --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap @@ -0,0 +1,51 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: Some( + [ + String("ok-status"), + String("ErrorCode"), + ], + ), + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} From ef1a992f2f968c802108b6efa23b9733339549ef Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 17:23:33 +0900 Subject: [PATCH 15/30] Add testcase --- crates/vespera_macro/src/parser/schema.rs | 21 ++++++++ ..._parse_enum_to_schema_unit_variants-2.snap | 51 +++++++++++++++++++ ..._parse_enum_to_schema_unit_variants-3.snap | 51 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 89f5e28..da7e4fa 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -738,6 +738,27 @@ mod tests { SchemaType::String, vec!["ok-status", "ErrorCode"] // rename_all is not applied in this path )] + #[case( + r#" + enum Simple { + First, + Second, + } + "#, + SchemaType::String, + vec!["First", "Second"] + )] + #[case( + r#" + #[serde(rename_all = "snake_case")] + enum Simple { + FirstItem, + SecondItem, + } + "#, + SchemaType::String, + vec!["first_item", "second_item"] + )] fn test_parse_enum_to_schema_unit_variants( #[case] enum_src: &str, #[case] expected_type: SchemaType, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap new file mode 100644 index 0000000..933db19 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap @@ -0,0 +1,51 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: Some( + [ + String("First"), + String("Second"), + ], + ), + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap new file mode 100644 index 0000000..07da71c --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap @@ -0,0 +1,51 @@ +--- +source: crates/vespera_macro/src/parser/schema.rs +expression: schema +--- +Schema { + ref_path: None, + schema_type: Some( + String, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: Some( + [ + String("first_item"), + String("second_item"), + ], + ), + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, +} From 09a17069ffa6968f7e49e399c270a26029374f96 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 17:35:26 +0900 Subject: [PATCH 16/30] Add testcase --- crates/vespera_macro/src/parser/parameters.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index e68810d..0e1c5d1 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -460,6 +460,7 @@ fn parse_query_struct_to_parameters( mod tests { use super::*; use rstest::rstest; + use serial_test::serial; use std::collections::HashMap; use vespera_core::route::ParameterLocation; use insta::assert_debug_snapshot; @@ -581,6 +582,7 @@ mod tests { vec![], vec![vec![ParameterLocation::Header]] )] + #[serial] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, From 9c45053eb2b3be065290856d36b6e7d1f502caa7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 19:09:40 +0900 Subject: [PATCH 17/30] Add testcase --- Cargo.lock | 76 ---------- crates/vespera_macro/Cargo.toml | 1 - crates/vespera_macro/src/parser/parameters.rs | 51 ++++--- .../vespera_macro/src/parser/request_body.rs | 22 +-- crates/vespera_macro/src/parser/schema.rs | 140 ++++++++++++++++-- ...parameter_cases@params_header_custom.snap} | 0 ...er_cases@params_header_value_and_arg.snap} | 0 ...ion_parameter_cases@params_json_body.snap} | 0 ...rameter_cases@params_method_receiver.snap} | 0 ...n_parameter_cases@params_path_single.snap} | 0 ...on_parameter_cases@params_path_tuple.snap} | 0 ..._cases@params_path_tuple_destructure.snap} | 0 ...parameter_cases@params_query_hashmap.snap} | 0 ...ion_parameter_cases@params_query_map.snap} | 0 ..._parameter_cases@params_query_struct.snap} | 0 ...parameter_cases@params_query_unknown.snap} | 0 ...on_parameter_cases@params_query_user.snap} | 0 ...er_cases@params_typed_header_and_arg.snap} | 0 ...eter_cases@params_typed_header_multi.snap} | 0 ...arse_request_body_cases@req_body_i32.snap} | 0 ...rse_request_body_cases@req_body_json.snap} | 0 ...arse_request_body_cases@req_body_str.snap} | 0 ...e_request_body_cases@req_body_string.snap} | 0 ...ed_variants@tuple_named_named_object.snap} | 0 ...med_variants@tuple_named_tuple_multi.snap} | 0 ...ed_variants@tuple_named_tuple_single.snap} | 0 ..._to_schema_unit_variants@unit_simple.snap} | 0 ...hema_unit_variants@unit_simple_snake.snap} | 0 ..._to_schema_unit_variants@unit_status.snap} | 0 29 files changed, 176 insertions(+), 114 deletions(-) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_custom.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_json_body.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_method_receiver.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple_destructure.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_hashmap.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_map.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_unknown.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_and_arg.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap => vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_multi.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap => vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_i32.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__request_body__tests__parse_request_body_cases.snap => vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_json.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap => vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_str.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap => vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_string.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap} (100%) rename crates/vespera_macro/src/parser/snapshots/{vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap => vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap} (100%) diff --git a/Cargo.lock b/Cargo.lock index c576b30..788a722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,21 +413,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -435,7 +420,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -444,17 +428,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.31" @@ -472,12 +445,6 @@ dependencies = [ "syn", ] -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - [[package]] name = "futures-task" version = "0.3.31" @@ -496,11 +463,9 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", - "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -1258,27 +1223,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "semver" version = "1.0.27" @@ -1364,31 +1314,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serial_test" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.6" @@ -1775,7 +1700,6 @@ dependencies = [ "rstest", "serde", "serde_json", - "serial_test", "syn", "tempfile", "vespera_core", diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index c8f25fa..9b180ed 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -23,4 +23,3 @@ anyhow = "1.0" rstest = "0.26" insta = "1.44" tempfile = "3" -serial_test = "3.2" diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 0e1c5d1..e0f1ecd 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -460,10 +460,9 @@ fn parse_query_struct_to_parameters( mod tests { use super::*; use rstest::rstest; - use serial_test::serial; use std::collections::HashMap; use vespera_core::route::ParameterLocation; - use insta::assert_debug_snapshot; + use insta::{assert_debug_snapshot, with_settings}; fn setup_test_data(func_src: &str) -> (HashMap, HashMap) { let mut struct_definitions = HashMap::new(); @@ -502,17 +501,20 @@ mod tests { #[case( "fn test(params: Path<(String, i32)>) {}", vec!["user_id".to_string(), "count".to_string()], - vec![vec![ParameterLocation::Path, ParameterLocation::Path]] + vec![vec![ParameterLocation::Path, ParameterLocation::Path]], + "path_tuple" )] #[case( "fn show(Path(id): Path) {}", vec!["item_id".to_string()], - vec![vec![ParameterLocation::Path]] + vec![vec![ParameterLocation::Path]], + "path_single" )] #[case( "fn test(Query(params): Query>) {}", vec![], - vec![vec![]] + vec![vec![]], + "query_hashmap" )] #[case( "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", @@ -520,7 +522,8 @@ mod tests { vec![ vec![ParameterLocation::Header], vec![], - ] + ], + "typed_header_and_arg" )] #[case( "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", @@ -529,7 +532,8 @@ mod tests { vec![ParameterLocation::Header], vec![ParameterLocation::Header], vec![ParameterLocation::Header], - ] + ], + "typed_header_multi" )] #[case( "fn test(user_agent: TypedHeader, count: i32) {}", @@ -537,7 +541,8 @@ mod tests { vec![ vec![ParameterLocation::Header], vec![], - ] + ], + "header_value_and_arg" )] #[case( "fn test(&self, id: i32) {}", @@ -545,48 +550,56 @@ mod tests { vec![ vec![], vec![], - ] + ], + "method_receiver" )] #[case( "fn test(Path((a, b)): Path<(i32, String)>) {}", vec![], - vec![vec![]] + vec![vec![]], + "path_tuple_destructure" )] #[case( "fn test(params: Query) {}", vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]] + vec![vec![ParameterLocation::Query, ParameterLocation::Query]], + "query_struct" )] #[case( "fn test(body: Json) {}", vec![], - vec![vec![]] + vec![vec![]], + "json_body" )] #[case( "fn test(params: Query) {}", vec![], - vec![vec![]] + vec![vec![]], + "query_unknown" )] #[case( "fn test(params: Query>) {}", vec![], - vec![vec![]] + vec![vec![]], + "query_map" )] #[case( "fn test(user: Query) {}", vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]] + vec![vec![ParameterLocation::Query, ParameterLocation::Query]], + "query_user" )] #[case( "fn test(custom: Header) {}", vec![], - vec![vec![ParameterLocation::Header]] + vec![vec![ParameterLocation::Header]], + "header_custom" )] - #[serial] fn test_parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, + #[case] suffix: &str, ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); @@ -621,7 +634,9 @@ mod tests { ); parameters.extend(params.clone()); } - assert_debug_snapshot!(parameters); + with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { + assert_debug_snapshot!(parameters); + }); } #[rstest] diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 4e32618..f411be8 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -90,24 +90,28 @@ pub fn parse_request_body( #[cfg(test)] mod tests { use super::*; - use insta::assert_debug_snapshot; + use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; - use serial_test::serial; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; #[rstest] - #[case::json("fn test(Json(payload): Json) {}", true)] - #[case::string("fn test(just_string: String) {}", true)] - #[case::str("fn test(just_str: &str) {}", true)] - #[case::i32("fn test(just_i32: i32) {}", false)] - #[serial] - fn test_parse_request_body_cases(#[case] func_src: &str, #[case] has_body: bool) { + #[case::json("fn test(Json(payload): Json) {}", true, "json")] + #[case::string("fn test(just_string: String) {}", true, "string")] + #[case::str("fn test(just_str: &str) {}", true, "str")] + #[case::i32("fn test(just_i32: i32) {}", false, "i32")] + fn test_parse_request_body_cases( + #[case] func_src: &str, + #[case] has_body: bool, + #[case] suffix: &str, + ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let arg = func.sig.inputs.first().unwrap(); let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); assert_eq!(body.is_some(), has_body); - assert_debug_snapshot!(body); + with_settings!({ snapshot_suffix => format!("req_body_{}", suffix) }, { + assert_debug_snapshot!(body); + }); } #[test] diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index da7e4fa..a07e662 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -700,7 +700,7 @@ mod tests { use rstest::rstest; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; - use insta::assert_debug_snapshot; + use insta::{assert_debug_snapshot, with_settings}; #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] @@ -736,7 +736,8 @@ mod tests { } "#, SchemaType::String, - vec!["ok-status", "ErrorCode"] // rename_all is not applied in this path + vec!["ok-status", "ErrorCode"], // rename_all is not applied in this path + "status" )] #[case( r#" @@ -746,7 +747,8 @@ mod tests { } "#, SchemaType::String, - vec!["First", "Second"] + vec!["First", "Second"], + "simple" )] #[case( r#" @@ -757,12 +759,14 @@ mod tests { } "#, SchemaType::String, - vec!["first_item", "second_item"] + vec!["first_item", "second_item"], + "simple_snake" )] fn test_parse_enum_to_schema_unit_variants( #[case] enum_src: &str, #[case] expected_type: SchemaType, #[case] expected_enum: Vec<&str>, + #[case] suffix: &str, ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); @@ -774,7 +778,9 @@ mod tests { .map(|v| v.as_str().unwrap().to_string()) .collect::>(); assert_eq!(got, expected_enum); - assert_debug_snapshot!(schema); + with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { + assert_debug_snapshot!(schema); + }); } #[rstest] @@ -786,7 +792,8 @@ mod tests { "#, 1, Some(SchemaType::String), - 0 // single-field tuple variant stored as object with inline schema + 0, // single-field tuple variant stored as object with inline schema + "tuple_single" )] #[case( r#" @@ -796,7 +803,8 @@ mod tests { "#, 1, Some(SchemaType::Array), - 2 // tuple array prefix_items length + 2, // tuple array prefix_items length + "tuple_multi" )] #[case( r#" @@ -806,20 +814,22 @@ mod tests { "#, 1, Some(SchemaType::Object), - 0 // not an array; ignore prefix_items length + 0, // not an array; ignore prefix_items length + "named_object" )] fn test_parse_enum_to_schema_tuple_and_named_variants( #[case] enum_src: &str, #[case] expected_one_of_len: usize, #[case] expected_inner_type: Option, #[case] expected_prefix_items_len: usize, + #[case] suffix: &str, ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); let one_of = schema.clone().one_of.expect("one_of missing"); assert_eq!(one_of.len(), expected_one_of_len); - if let Some(inner_expected) = expected_inner_type { + if let Some(inner_expected) = expected_inner_type.clone() { if let SchemaRef::Inline(obj) = &one_of[0] { let props = obj.properties.as_ref().expect("props missing"); // take first property value @@ -856,7 +866,42 @@ mod tests { } } - assert_debug_snapshot!(schema); + with_settings!({ snapshot_suffix => format!("tuple_named_{}", suffix) }, { + assert_debug_snapshot!(schema); + }); + } + + #[rstest] + #[case( + r#" + enum Mixed { + Ready, + Data(String), + } + "#, + 2, + SchemaType::String, + "Ready" + )] + fn test_parse_enum_to_schema_mixed_unit_variant( + #[case] enum_src: &str, + #[case] expected_one_of_len: usize, + #[case] expected_unit_type: SchemaType, + #[case] expected_unit_value: &str, + ) { + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing for mixed enum"); + assert_eq!(one_of.len(), expected_one_of_len); + + let unit_schema = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema for unit variant"), + }; + assert_eq!(unit_schema.schema_type, Some(expected_unit_type)); + let unit_enum = unit_schema.r#enum.as_ref().expect("enum values missing"); + assert_eq!(unit_enum[0].as_str().unwrap(), expected_unit_value); } #[test] @@ -878,6 +923,16 @@ mod tests { assert!(!schema.required.as_ref().unwrap().contains(&"name".to_string())); } + #[rstest] + #[case("struct Wrapper(i32);")] + #[case("struct Empty;")] + fn test_parse_struct_to_schema_tuple_and_unit_structs(#[case] struct_src: &str) { + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + assert!(schema.properties.is_none()); + assert!(schema.required.is_none()); + } + #[test] fn test_parse_type_to_schema_ref_empty_path_and_reference() { // Empty path segments returns object @@ -943,6 +998,71 @@ mod tests { } } + #[rstest] + #[case("$invalid", "String")] + fn test_substitute_type_parse_failure_uses_original( + #[case] invalid: &str, + #[case] concrete_src: &str, + ) { + use proc_macro2::TokenStream; + use std::str::FromStr; + + let ty = Type::Verbatim(TokenStream::from_str(invalid).unwrap()); + let concrete: Type = syn::parse_str(concrete_src).unwrap(); + let substituted = substitute_type(&ty, &[String::from("T")], &[&concrete]); + assert_eq!(substituted, ty); + } + + #[rstest] + #[case("&i32")] + #[case("std::string::String")] + fn test_is_primitive_type_non_path_variants(#[case] ty_src: &str) { + let ty: Type = syn::parse_str(ty_src).unwrap(); + assert!(!is_primitive_type(&ty)); + } + + #[rstest] + #[case("HashMap", true, None, Some("#/components/schemas/Value"))] + #[case("Result", false, Some(SchemaType::Object), None)] + #[case("crate::Value", false, None, None)] + #[case("(i32, bool)", false, Some(SchemaType::Object), None)] + fn test_parse_type_to_schema_ref_additional_cases( + #[case] ty_src: &str, + #[case] expect_additional_props: bool, + #[case] expected_type: Option, + #[case] expected_ref: Option<&str>, + ) { + let mut known_schemas = HashMap::new(); + known_schemas.insert("Value".to_string(), "Value".to_string()); + + let ty: Type = syn::parse_str(ty_src).unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + match expected_ref { + Some(expected) => { + let SchemaRef::Inline(schema) = schema_ref else { + panic!("Expected inline schema for {}", ty_src); + }; + let additional = schema + .additional_properties + .as_ref() + .expect("additional_properties missing"); + assert_eq!(additional.get("$ref").unwrap(), expected); + } + None => match schema_ref { + SchemaRef::Inline(schema) => { + if expect_additional_props { + assert!(schema.additional_properties.is_some()); + } else { + assert_eq!(schema.schema_type, expected_type); + } + } + SchemaRef::Ref(_) => { + assert!(ty_src.contains("Value")); + } + }, + } + } + #[rstest] // camelCase tests #[case("user_name", Some("camelCase"), "userName")] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_custom.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-14.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_custom.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-4.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_json_body.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-10.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_json_body.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_method_receiver.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-11.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_method_receiver.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-2.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple_destructure.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-12.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple_destructure.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_hashmap.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-3.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_hashmap.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_map.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-7.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_map.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-9.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_unknown.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-8.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_unknown.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-13.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_and_arg.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-6.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_and_arg.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_multi.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases-5.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_multi.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_i32.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-4.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_i32.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_json.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_json.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_str.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-2.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_str.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_string.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases-3.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_string.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-3.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants-2.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-2.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants-3.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap similarity index 100% rename from crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants.snap rename to crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap From e4f19469a292a174e60b893ff8682a0e86ff0d61 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 19:14:11 +0900 Subject: [PATCH 18/30] Add out --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c5f3201..6bcf2ef 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -47,7 +47,7 @@ jobs: echo 'merge_derives = true' >> .rustfmt.toml echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - cargo tarpaulin --out Lcov + cargo tarpaulin --out Lcov Stdout - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: From 85d980cabe9c2b96c4439b0be7df501429c2a00e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 20:12:12 +0900 Subject: [PATCH 19/30] Add coverage --- crates/vespera_macro/src/parser/operation.rs | 74 +++---- crates/vespera_macro/src/parser/parameters.rs | 98 ++++----- crates/vespera_macro/src/parser/schema.rs | 201 +++++++++++++++++- ...m_to_schema_unit_variants@unit_status.snap | 2 +- 4 files changed, 276 insertions(+), 99 deletions(-) diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 52e1ad2..38369ce 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -160,45 +160,45 @@ pub fn build_operation_from_function( } // Fallback: if last arg is String/&str and no body yet, treat as text/plain body - if request_body.is_none() { - if let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { - let is_string = match ty.as_ref() { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false), - Type::Reference(type_ref) => { - if let Type::Path(p) = type_ref.elem.as_ref() { - p.path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false) - } else { - false - } + if request_body.is_none() + && let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() + { + let is_string = match ty.as_ref() { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false), + Type::Reference(type_ref) => { + if let Type::Path(p) = type_ref.elem.as_ref() { + p.path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false) + } else { + false } - _ => false, - }; - - if is_string { - let mut content = BTreeMap::new(); - content.insert( - "text/plain".to_string(), - MediaType { - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - examples: None, - }, - ); - request_body = Some(RequestBody { - description: None, - content, - required: Some(true), - }); } + _ => false, + }; + + if is_string { + let mut content = BTreeMap::new(); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + examples: None, + }, + ); + request_body = Some(RequestBody { + description: None, + content, + required: Some(true), + }); } } diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index e0f1ecd..9e43919 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -47,26 +47,25 @@ pub fn parse_function_parameter( let ident_str = segment.ident.to_string(); // Handle Option> - if ident_str == "Option" { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Type::Path(inner_type_path) = inner_ty - && !inner_type_path.path.segments.is_empty() - { - let inner_segment = inner_type_path.path.segments.last().unwrap(); - let inner_ident_str = inner_segment.ident.to_string(); + if ident_str == "Option" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Type::Path(inner_type_path) = inner_ty + && !inner_type_path.path.segments.is_empty() + { + let inner_segment = inner_type_path.path.segments.last().unwrap(); + let inner_ident_str = inner_segment.ident.to_string(); - if inner_ident_str == "TypedHeader" { - // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace("_", "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(false), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); - } + if inner_ident_str == "TypedHeader" { + // TypedHeader always uses string schema regardless of inner type + return Some(vec![Parameter { + name: param_name.replace("_", "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(false), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + }]); } } } @@ -119,11 +118,11 @@ pub fn parse_function_parameter( } } else { // Single path parameter - // Allow only when exactly one path parameter is provided - if path_params.len() != 1 { - return None; - } - let name = path_params[0].clone(); + // Allow only when exactly one path parameter is provided + if path_params.len() != 1 { + return None; + } + let name = path_params[0].clone(); return Some(vec![Parameter { name, r#in: ParameterLocation::Path, @@ -268,16 +267,16 @@ fn is_primitive_like(ty: &Type) -> bool { if is_primitive_type(ty) { return true; } - if let Type::Path(type_path) = ty { - if let Some(seg) = type_path.path.segments.last() { - let ident = seg.ident.to_string(); - if let syn::PathArguments::AngleBracketed(args) = &seg.arguments { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - if (ident == "Vec" || ident == "Option") && is_primitive_like(inner_ty) { - return true; - } - } - } + if let Type::Path(type_path) = ty + && let Some(seg) = type_path.path.segments.last() + { + let ident = seg.ident.to_string(); + if let syn::PathArguments::AngleBracketed(args) = &seg.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && (ident == "Vec" || ident == "Option") + && is_primitive_like(inner_ty) + { + return true; } } false @@ -459,15 +458,15 @@ fn parse_query_struct_to_parameters( #[cfg(test)] mod tests { use super::*; + use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use std::collections::HashMap; use vespera_core::route::ParameterLocation; - use insta::{assert_debug_snapshot, with_settings}; fn setup_test_data(func_src: &str) -> (HashMap, HashMap) { let mut struct_definitions = HashMap::new(); let known_schemas: HashMap = HashMap::new(); - + if func_src.contains("QueryParams") { struct_definitions.insert( "QueryParams".to_string(), @@ -480,7 +479,7 @@ mod tests { .to_string(), ); } - + if func_src.contains("User") { struct_definitions.insert( "User".to_string(), @@ -493,7 +492,7 @@ mod tests { .to_string(), ); } - + (known_schemas, struct_definitions) } @@ -604,14 +603,10 @@ mod tests { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); let mut parameters = Vec::new(); - + for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = parse_function_parameter( - arg, - &path_params, - &known_schemas, - &struct_definitions, - ); + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); let expected = expected_locations .get(idx) .unwrap_or_else(|| expected_locations.last().unwrap()); @@ -682,15 +677,14 @@ mod tests { "pub struct User { pub id: i32 }".to_string(), ); let mut known_schemas = known_schemas; - known_schemas.insert("CustomHeader".to_string(), "#/components/schemas/CustomHeader".to_string()); + known_schemas.insert( + "CustomHeader".to_string(), + "#/components/schemas/CustomHeader".to_string(), + ); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = parse_function_parameter( - arg, - &path_params, - &known_schemas, - &struct_definitions, - ); + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); assert!( result.is_none(), "Expected None at arg index {}, func: {}, got: {:?}", diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index a07e662..6dcaca7 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -37,6 +37,10 @@ pub(super) fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { // Look for rename = "..." pattern if let Some(start) = tokens.find("rename") { + // Avoid false positives from rename_all + if tokens[start..].starts_with("rename_all") { + continue; + } let remaining = &tokens[start + "rename".len()..]; if let Some(equals_pos) = remaining.find('=') { let value_part = &remaining[equals_pos + 1..].trim(); @@ -84,8 +88,21 @@ pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String result } Some("kebab-case") => { - // Convert snake_case to kebab-case - field_name.replace('_', "-") + // Convert snake_case or Camel/PascalCase to kebab-case (lowercase with hyphens) + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() { + if i > 0 && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap_or(ch)); + } else if ch == '_' { + result.push('-'); + } else { + result.push(ch); + } + } + result } Some("PascalCase") => { // Convert snake_case to PascalCase @@ -697,10 +714,10 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( #[cfg(test)] mod tests { use super::*; + use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; - use insta::{assert_debug_snapshot, with_settings}; #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] @@ -736,7 +753,7 @@ mod tests { } "#, SchemaType::String, - vec!["ok-status", "ErrorCode"], // rename_all is not applied in this path + vec!["ok-status", "error-code"], // rename_all is not applied in this path "status" )] #[case( @@ -771,7 +788,8 @@ mod tests { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); assert_eq!(schema.schema_type, Some(expected_type)); - let got = schema.clone() + let got = schema + .clone() .r#enum .unwrap() .iter() @@ -854,7 +872,13 @@ mod tests { let inner_props = inner_obj.properties.as_ref().unwrap(); assert!(inner_props.contains_key("id")); assert!(inner_props.contains_key("note")); - assert!(inner_obj.required.as_ref().unwrap().contains(&"id".to_string())); + assert!( + inner_obj + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); } else { panic!("Expected inline object schema"); } @@ -904,6 +928,125 @@ mod tests { assert_eq!(unit_enum[0].as_str().unwrap(), expected_unit_value); } + #[test] + fn test_parse_enum_to_schema_rename_all_for_data_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "kebab-case")] + enum Payload { + DataItem(String), + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + let variant_obj = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + assert!(props.contains_key("data-item")); + } + + #[test] + fn test_parse_enum_to_schema_field_uses_enum_rename_all() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "snake_case")] + enum Event { + Detail { UserId: i32 }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + let variant_obj = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + let inner = match props.get("detail").expect("variant key missing") { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline inner schema"), + }; + let inner_props = inner.properties.as_ref().expect("inner props missing"); + assert!(inner_props.contains_key("user_id")); + assert!(!inner_props.contains_key("UserId")); + } + + #[test] + fn test_parse_enum_to_schema_variant_rename_overrides_rename_all() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "snake_case")] + enum Payload { + #[serde(rename = "Explicit")] + DataItem(i32), + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + let variant_obj = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + assert!(props.contains_key("Explicit")); + assert!(!props.contains_key("data_item")); + } + + #[test] + fn test_parse_enum_to_schema_field_rename_overrides_variant_rename_all() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "snake_case")] + enum Payload { + #[serde(rename_all = "kebab-case")] + Detail { #[serde(rename = "ID")] user_id: i32 }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + let variant_obj = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + let inner = match props + .get("detail") + .or_else(|| props.get("Detail")) + .expect("variant key missing") + { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline inner schema"), + }; + let inner_props = inner.properties.as_ref().expect("inner props missing"); + assert!(inner_props.contains_key("ID")); // field-level rename wins + assert!(!inner_props.contains_key("user-id")); // variant rename_all ignored for this field + } + #[test] fn test_parse_struct_to_schema_required_optional() { let struct_item: syn::ItemStruct = syn::parse_str( @@ -919,8 +1062,43 @@ mod tests { let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("id")); assert!(props.contains_key("name")); - assert!(schema.required.as_ref().unwrap().contains(&"id".to_string())); - assert!(!schema.required.as_ref().unwrap().contains(&"name".to_string())); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); + assert!( + !schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); + } + + #[test] + fn test_parse_struct_to_schema_rename_all_and_field_rename() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(rename_all = "camelCase")] + struct Profile { + #[serde(rename = "id")] + user_id: i32, + display_name: Option, + } + "#, + ) + .unwrap(); + + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + let props = schema.properties.as_ref().expect("props missing"); + assert!(props.contains_key("id")); // field-level rename wins + assert!(props.contains_key("displayName")); // rename_all applied + let required = schema.required.as_ref().expect("required missing"); + assert!(required.contains(&"id".to_string())); + assert!(!required.contains(&"displayName".to_string())); // Option makes it optional } #[rstest] @@ -1022,7 +1200,12 @@ mod tests { } #[rstest] - #[case("HashMap", true, None, Some("#/components/schemas/Value"))] + #[case( + "HashMap", + true, + None, + Some("#/components/schemas/Value") + )] #[case("Result", false, Some(SchemaType::Object), None)] #[case("crate::Value", false, None, None)] #[case("(i32, bool)", false, Some(SchemaType::Object), None)] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap index ddef11c..e42df38 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap @@ -34,7 +34,7 @@ Schema { enum: Some( [ String("ok-status"), - String("ErrorCode"), + String("error-code"), ], ), all_of: None, From 8c4ac5dca8ad73b77b726e4d8029a4c988ae86f6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 20:12:47 +0900 Subject: [PATCH 20/30] Add checking lint --- .github/workflows/CI.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6bcf2ef..081bae0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,6 +35,8 @@ jobs: components: clippy, rustfmt - name: Build run: cargo check + - name: Lint + run: cargo clippy --all-targets --all-features -- -D warnings - name: Test run: | # rust coverage issue From a3a638197ba418bad4168a058be41083a650dd6b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 20:24:19 +0900 Subject: [PATCH 21/30] Fix lint --- crates/vespera_macro/src/args.rs | 5 +-- crates/vespera_macro/src/collector.rs | 41 +++++-------------- crates/vespera_macro/src/lib.rs | 12 +++--- crates/vespera_macro/src/openapi_generator.rs | 6 +-- crates/vespera_macro/src/parser/response.rs | 8 ++-- crates/vespera_macro/src/route/utils.rs | 8 ++-- 6 files changed, 28 insertions(+), 52 deletions(-) diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index 57d5d2e..bece7f1 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -180,11 +180,8 @@ mod tests { if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. - }) = elem - { - if let Ok(code) = lit_int.base10_parse::() { + }) = elem && let Ok(code) = lit_int.base10_parse::() { status_codes.push(code); - } } } assert_eq!( diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index c820911..7539ce6 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -118,8 +118,6 @@ pub fn get_users() -> String { } "#, )], - 1, // expected routes - 0, // expected structs "get", "/users", "get_users", @@ -136,8 +134,6 @@ pub fn create_user() -> String { } "#, )], - 1, - 0, "post", "/create-user", "create_user", @@ -154,8 +150,6 @@ pub fn get_users() -> String { } "#, )], - 1, - 0, "get", "/users/api/users", "get_users", @@ -172,8 +166,6 @@ pub fn get_users() -> String { } "#, )], - 1, - 0, "get", "/users", "get_users", @@ -190,8 +182,6 @@ pub fn get_users() -> String { } "#, )], - 1, - 0, "get", "/api/users", "get_users", @@ -208,8 +198,6 @@ pub fn get_users() -> String { } "#, )], - 1, - 0, "get", "/api/v1/users", "get_users", @@ -218,8 +206,6 @@ pub fn get_users() -> String { fn test_collect_metadata_routes( #[case] folder_name: &str, #[case] files: Vec<(&str, &str)>, - #[case] expected_routes: usize, - #[case] expected_structs: usize, #[case] expected_method: &str, #[case] expected_path: &str, #[case] expected_function_name: &str, @@ -233,22 +219,17 @@ pub fn get_users() -> String { let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - assert_eq!(metadata.routes.len(), expected_routes); - assert_eq!(metadata.structs.len(), expected_structs); - - if expected_routes > 0 { - let route = &metadata.routes[0]; - assert_eq!(route.method, expected_method); - assert_eq!(route.path, expected_path); - assert_eq!(route.function_name, expected_function_name); - assert_eq!(route.module_path, expected_module_path); - if let Some((first_filename, _)) = files.first() { - assert!( - route - .file_path - .contains(first_filename.split('/').next().unwrap()) - ); - } + let route = &metadata.routes[0]; + assert_eq!(route.method, expected_method); + assert_eq!(route.path, expected_path); + assert_eq!(route.function_name, expected_function_name); + assert_eq!(route.module_path, expected_module_path); + if let Some((first_filename, _)) = files.first() { + assert!( + route + .file_path + .contains(first_filename.split('/').next().unwrap()) + ); } drop(temp_dir); diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 25822fb..e11f644 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -435,7 +435,7 @@ mod tests { let folder_name = "routes"; let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); @@ -592,7 +592,7 @@ pub fn get_users() -> String { } let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); @@ -677,7 +677,7 @@ pub fn update_user() -> String { ); let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); @@ -731,7 +731,7 @@ pub fn create_users() -> String { ); let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); @@ -777,7 +777,7 @@ pub fn index() -> String { ); let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); @@ -813,7 +813,7 @@ pub fn get_users() -> String { ); let result = generate_router_code( - &collect_metadata(&temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None, ); diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 4159072..b371216 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -494,10 +494,8 @@ pub fn create_user() -> String { assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); assert!(schemas.contains_key("User")); - } else { - if let Some(schemas) = doc.components.as_ref().unwrap().schemas.as_ref() { - assert!(!schemas.contains_key("User")); - } + } else if let Some(schemas) = doc.components.as_ref().unwrap().schemas.as_ref() { + assert!(!schemas.contains_key("User")); } // Check route diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index d8ddfa7..088327d 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -377,10 +377,10 @@ mod tests { assert_eq!(schema.schema_type, Some(SchemaType::Array)); assert!(schema.items.is_some()); // Check that items is String - if let Some(items) = &schema.items { - if let SchemaRef::Inline(items_schema) = items.as_ref() { - assert_eq!(items_schema.schema_type, Some(SchemaType::String)); - } + if let Some(items) = &schema.items + && let SchemaRef::Inline(items_schema) = items.as_ref() + { + assert_eq!(items_schema.schema_type, Some(SchemaType::String)); } } else { panic!("Expected inline Array schema"); diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 35c2432..75f8239 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -122,10 +122,10 @@ mod tests { let file: syn::File = syn::parse_str(&full_code).expect("Failed to parse with attribute"); // Extract the first attribute from the function - if let Some(syn::Item::Fn(fn_item)) = file.items.first() { - if let Some(attr) = fn_item.attrs.first() { - return attr.meta.clone(); - } + if let Some(syn::Item::Fn(fn_item)) = file.items.first() + && let Some(attr) = fn_item.attrs.first() + { + return attr.meta.clone(); } panic!("Failed to extract meta from attribute: {}", attr_str); From 6fa6b747e020f003edea898147abf46cf3a5d127 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 20:40:51 +0900 Subject: [PATCH 22/30] Add schema testcase --- .rustfmt.toml | 8 + crates/vespera_core/src/route.rs | 124 +----- crates/vespera_core/src/schema.rs | 93 +++-- crates/vespera_macro/src/args.rs | 90 +---- crates/vespera_macro/src/collector.rs | 61 +-- crates/vespera_macro/src/file_utils.rs | 52 +-- crates/vespera_macro/src/lib.rs | 224 ++--------- crates/vespera_macro/src/metadata.rs | 5 +- crates/vespera_macro/src/method.rs | 54 +-- crates/vespera_macro/src/openapi_generator.rs | 198 ++-------- crates/vespera_macro/src/parser/operation.rs | 156 +------- crates/vespera_macro/src/parser/parameters.rs | 233 ++--------- .../vespera_macro/src/parser/request_body.rs | 66 +--- crates/vespera_macro/src/parser/response.rs | 141 ++----- crates/vespera_macro/src/parser/schema.rs | 374 +++--------------- crates/vespera_macro/src/route/utils.rs | 114 +----- examples/axum-example/src/lib.rs | 9 +- examples/axum-example/src/routes/error.rs | 35 +- examples/axum-example/src/routes/foo/mod.rs | 17 +- examples/axum-example/src/routes/generic.rs | 32 +- examples/axum-example/src/routes/mod.rs | 38 +- examples/axum-example/src/routes/path/mod.rs | 16 +- .../axum-example/src/routes/typed_header.rs | 9 +- examples/axum-example/src/routes/users.rs | 25 +- .../axum-example/tests/integration_test.rs | 10 +- 25 files changed, 360 insertions(+), 1824 deletions(-) create mode 100644 .rustfmt.toml diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..0a6a768 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,8 @@ +max_width = 100000 +tab_spaces = 4 +newline_style = "Unix" +fn_call_width = 100000 +fn_params_layout = "Compressed" +chain_width = 100000 +merge_derives = true +use_small_heuristics = "Default" diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index 123dc88..a3987c4 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -319,48 +319,21 @@ mod tests { #[test] fn test_path_item_set_operation() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; - - let operation = Operation { - operation_id: Some("test_operation".to_string()), - tags: None, - summary: None, - description: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }; + let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; + + let operation = Operation { operation_id: Some("test_operation".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; // Test setting GET operation path_item.set_operation(HttpMethod::Get, operation.clone()); assert!(path_item.get.is_some()); - assert_eq!( - path_item.get.as_ref().unwrap().operation_id, - Some("test_operation".to_string()) - ); + assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("test_operation".to_string())); // Test setting POST operation let mut operation_post = operation.clone(); operation_post.operation_id = Some("post_operation".to_string()); path_item.set_operation(HttpMethod::Post, operation_post); assert!(path_item.post.is_some()); - assert_eq!( - path_item.post.as_ref().unwrap().operation_id, - Some("post_operation".to_string()) - ); + assert_eq!(path_item.post.as_ref().unwrap().operation_id, Some("post_operation".to_string())); // Test setting PUT operation let mut operation_put = operation.clone(); @@ -401,30 +374,9 @@ mod tests { #[test] fn test_path_item_get_operation() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; - - let operation = Operation { - operation_id: Some("test_operation".to_string()), - tags: None, - summary: None, - description: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }; + let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; + + let operation = Operation { operation_id: Some("test_operation".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; // Initially, all operations should be None assert!(path_item.get_operation(&HttpMethod::Get).is_none()); @@ -434,10 +386,7 @@ mod tests { path_item.set_operation(HttpMethod::Get, operation.clone()); let retrieved = path_item.get_operation(&HttpMethod::Get); assert!(retrieved.is_some()); - assert_eq!( - retrieved.unwrap().operation_id, - Some("test_operation".to_string()) - ); + assert_eq!(retrieved.unwrap().operation_id, Some("test_operation".to_string())); // Set POST operation let mut operation_post = operation.clone(); @@ -445,10 +394,7 @@ mod tests { path_item.set_operation(HttpMethod::Post, operation_post); let retrieved = path_item.get_operation(&HttpMethod::Post); assert!(retrieved.is_some()); - assert_eq!( - retrieved.unwrap().operation_id, - Some("post_operation".to_string()) - ); + assert_eq!(retrieved.unwrap().operation_id, Some("post_operation".to_string())); // Test all methods path_item.set_operation(HttpMethod::Put, operation.clone()); @@ -472,55 +418,19 @@ mod tests { #[test] fn test_path_item_set_operation_overwrites() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; - - let operation1 = Operation { - operation_id: Some("first".to_string()), - tags: None, - summary: None, - description: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }; - - let operation2 = Operation { - operation_id: Some("second".to_string()), - tags: None, - summary: None, - description: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }; + let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; + + let operation1 = Operation { operation_id: Some("first".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; + + let operation2 = Operation { operation_id: Some("second".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; // Set first operation path_item.set_operation(HttpMethod::Get, operation1); - assert_eq!( - path_item.get.as_ref().unwrap().operation_id, - Some("first".to_string()) - ); + assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("first".to_string())); // Overwrite with second operation path_item.set_operation(HttpMethod::Get, operation2); - assert_eq!( - path_item.get.as_ref().unwrap().operation_id, - Some("second".to_string()) - ); + assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("second".to_string())); } #[test] diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 236399e..a2974b6 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -214,46 +214,7 @@ pub struct Schema { 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, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - r#enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - } + Self { ref_path: None, schema_type: Some(schema_type), format: None, title: None, description: None, default: None, example: None, examples: None, minimum: None, maximum: None, exclusive_minimum: None, exclusive_maximum: None, multiple_of: None, min_length: None, max_length: None, pattern: None, items: None, prefix_items: None, min_items: None, max_items: None, unique_items: None, properties: None, required: None, additional_properties: None, min_properties: None, max_properties: None, r#enum: None, all_of: None, any_of: None, one_of: None, not: None, nullable: None, read_only: None, write_only: None, external_docs: None, defs: None, dynamic_anchor: None, dynamic_ref: None } } /// Create a string schema @@ -278,19 +239,12 @@ impl Schema { /// Create an array schema pub fn array(items: SchemaRef) -> Self { - Self { - items: Some(Box::new(items)), - ..Self::new(SchemaType::Array) - } + Self { items: Some(Box::new(items)), ..Self::new(SchemaType::Array) } } /// Create an object schema pub fn object() -> Self { - Self { - properties: Some(BTreeMap::new()), - required: Some(Vec::new()), - ..Self::new(SchemaType::Object) - } + Self { properties: Some(BTreeMap::new()), required: Some(Vec::new()), ..Self::new(SchemaType::Object) } } } @@ -371,3 +325,44 @@ pub trait SchemaBuilder: Sized { // This trait is used as a marker for derive macro // The actual schema conversion will be implemented separately } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(Schema::string(), SchemaType::String)] + #[case(Schema::integer(), SchemaType::Integer)] + #[case(Schema::number(), SchemaType::Number)] + #[case(Schema::boolean(), SchemaType::Boolean)] + fn primitive_helpers_set_schema_type(#[case] schema: Schema, #[case] expected: SchemaType) { + assert_eq!(schema.schema_type, Some(expected)); + } + + #[test] + fn array_helper_sets_type_and_items() { + let item_schema = Schema::boolean(); + let schema = Schema::array(SchemaRef::Inline(Box::new(item_schema.clone()))); + + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + let items = schema.items.expect("items should be set"); + match *items { + SchemaRef::Inline(inner) => { + assert_eq!(inner.schema_type, Some(SchemaType::Boolean)); + } + SchemaRef::Ref(_) => panic!("array helper should set inline items"), + } + } + + #[test] + fn object_helper_initializes_collections() { + let schema = Schema::object(); + + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let props = schema.properties.expect("properties should be initialized"); + assert!(props.is_empty()); + let required = schema.required.expect("required should be initialized"); + assert!(required.is_empty()); + } +} diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index bece7f1..fd43128 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -48,11 +48,7 @@ impl syn::parse::Parse for RouteArgs { } } - Ok(RouteArgs { - method, - path, - error_status, - }) + Ok(RouteArgs { method, path, error_status }) } } @@ -109,103 +105,49 @@ mod tests { #[case(",", false, None, None, None)] #[case("get, 123", false, None, None, None)] #[case("get, =", false, None, None, None)] - fn test_route_args_parse( - #[case] input: &str, - #[case] should_parse: bool, - #[case] expected_method: Option<&str>, - #[case] expected_path: Option<&str>, - #[case] expected_error_status: Option>, - ) { + fn test_route_args_parse(#[case] input: &str, #[case] should_parse: bool, #[case] expected_method: Option<&str>, #[case] expected_path: Option<&str>, #[case] expected_error_status: Option>) { let result = syn::parse_str::(input); match (should_parse, result) { (true, Ok(route_args)) => { // Check method if let Some(exp_method) = expected_method { - assert!( - route_args.method.is_some(), - "Expected method {} but got None for input: {}", - exp_method, - input - ); - assert_eq!( - route_args.method.as_ref().unwrap().to_string(), - exp_method, - "Method mismatch for input: {}", - input - ); + assert!(route_args.method.is_some(), "Expected method {} but got None for input: {}", exp_method, input); + assert_eq!(route_args.method.as_ref().unwrap().to_string(), exp_method, "Method mismatch for input: {}", input); } else { - assert!( - route_args.method.is_none(), - "Expected no method but got {:?} for input: {}", - route_args.method, - input - ); + assert!(route_args.method.is_none(), "Expected no method but got {:?} for input: {}", route_args.method, input); } // Check path if let Some(exp_path) = expected_path { - assert!( - route_args.path.is_some(), - "Expected path {} but got None for input: {}", - exp_path, - input - ); - assert_eq!( - route_args.path.as_ref().unwrap().value(), - exp_path, - "Path mismatch for input: {}", - input - ); + assert!(route_args.path.is_some(), "Expected path {} but got None for input: {}", exp_path, input); + assert_eq!(route_args.path.as_ref().unwrap().value(), exp_path, "Path mismatch for input: {}", input); } else { - assert!( - route_args.path.is_none(), - "Expected no path but got {:?} for input: {}", - route_args.path, - input - ); + assert!(route_args.path.is_none(), "Expected no path but got {:?} for input: {}", route_args.path, input); } // Check error_status if let Some(exp_status) = expected_error_status { - assert!( - route_args.error_status.is_some(), - "Expected error_status {:?} but got None for input: {}", - exp_status, - input - ); + assert!(route_args.error_status.is_some(), "Expected error_status {:?} but got None for input: {}", exp_status, input); let array = route_args.error_status.as_ref().unwrap(); let mut status_codes = Vec::new(); for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(lit_int), - .. - }) = elem && let Ok(code) = lit_int.base10_parse::() { - status_codes.push(code); + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. }) = elem + && let Ok(code) = lit_int.base10_parse::() + { + status_codes.push(code); } } - assert_eq!( - status_codes, exp_status, - "Error status mismatch for input: {}", - input - ); + assert_eq!(status_codes, exp_status, "Error status mismatch for input: {}", input); } else { - assert!( - route_args.error_status.is_none(), - "Expected no error_status but got {:?} for input: {}", - route_args.error_status, - input - ); + assert!(route_args.error_status.is_none(), "Expected no error_status but got {:?} for input: {}", route_args.error_status, input); } } (false, Err(_)) => { // Expected error, test passes } (true, Err(e)) => { - panic!( - "Expected successful parse but got error: {} for input: {}", - e, input - ); + panic!("Expected successful parse but got error: {} for input: {}", e, input); } (false, Ok(_)) => { panic!("Expected parse error but got success for input: {}", input); diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 7539ce6..fa93428 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -11,39 +11,21 @@ use syn::Item; pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result { let mut metadata = CollectedMetadata::new(); - let files = collect_files(folder_path).with_context(|| { - format!( - "Failed to collect files from wtf: {}", - folder_path.display() - ) - })?; + let files = collect_files(folder_path).with_context(|| format!("Failed to collect files from wtf: {}", folder_path.display()))?; for file in files { if !file.extension().map(|e| e == "rs").unwrap_or(false) { continue; } - let content = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; + let content = std::fs::read_to_string(&file).with_context(|| format!("Failed to read file: {}", file.display()))?; - let file_ast = syn::parse_file(&content) - .with_context(|| format!("Failed to parse file: {}", file.display()))?; + let file_ast = syn::parse_file(&content).with_context(|| format!("Failed to parse file: {}", file.display()))?; // Get module path - let segments = file - .strip_prefix(folder_path) - .map(|file_stem| file_to_segments(file_stem, folder_path)) - .context(format!( - "Failed to strip prefix from file: {} (base: {})", - file.display(), - folder_path.display() - ))?; - - let module_path = if folder_name.is_empty() { - segments.join("::") - } else { - format!("{}::{}", folder_name, segments.join("::")) - }; + let segments = file.strip_prefix(folder_path).map(|file_stem| file_to_segments(file_stem, folder_path)).context(format!("Failed to strip prefix from file: {} (base: {})", file.display(), folder_path.display()))?; + + let module_path = if folder_name.is_empty() { segments.join("::") } else { format!("{}::{}", folder_name, segments.join("::")) }; let file_path = file.display().to_string(); @@ -61,15 +43,7 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result String { "get_users", "routes::api::v1::users", )] - fn test_collect_metadata_routes( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_name: &str, - #[case] expected_module_path: &str, - ) { + fn test_collect_metadata_routes(#[case] folder_name: &str, #[case] files: Vec<(&str, &str)>, #[case] expected_method: &str, #[case] expected_path: &str, #[case] expected_function_name: &str, #[case] expected_module_path: &str) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); for (filename, content) in &files { @@ -225,11 +192,7 @@ pub fn get_users() -> String { assert_eq!(route.function_name, expected_function_name); assert_eq!(route.module_path, expected_module_path); if let Some((first_filename, _)) = files.first() { - assert!( - route - .file_path - .contains(first_filename.split('/').next().unwrap()) - ); + assert!(route.file_path.contains(first_filename.split('/').next().unwrap())); } drop(temp_dir); @@ -343,11 +306,7 @@ pub fn get_posts() -> String { assert_eq!(metadata.structs.len(), 0); // Check all routes are present - let function_names: Vec<&str> = metadata - .routes - .iter() - .map(|r| r.function_name.as_str()) - .collect(); + let function_names: Vec<&str> = metadata.routes.iter().map(|r| r.function_name.as_str()).collect(); assert!(function_names.contains(&"get_users")); assert!(function_names.contains(&"create_users")); assert!(function_names.contains(&"get_posts")); diff --git a/crates/vespera_macro/src/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index 690a9e9..9375ddc 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -3,9 +3,7 @@ use std::path::{Path, PathBuf}; pub fn collect_files(folder_path: &Path) -> Result> { let mut files = Vec::new(); - for entry in std::fs::read_dir(folder_path) - .with_context(|| format!("Failed to read directory: {}", folder_path.display()))? - { + for entry in std::fs::read_dir(folder_path).with_context(|| format!("Failed to read directory: {}", folder_path.display()))? { let entry = entry.with_context(|| "Failed to read directory entry")?; let path = entry.path(); if path.is_file() { @@ -18,17 +16,9 @@ pub fn collect_files(folder_path: &Path) -> Result> { } pub fn file_to_segments(file: &Path, base_path: &Path) -> Vec { - let file_stem = if let Ok(file_stem) = file.strip_prefix(base_path) { - file_stem.display().to_string() - } else { - file.display().to_string() - }; + let file_stem = if let Ok(file_stem) = file.strip_prefix(base_path) { file_stem.display().to_string() } else { file.display().to_string() }; let file_stem = file_stem.replace(".rs", "").replace("\\", "/"); - let mut segments: Vec = file_stem - .split("/") - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); + let mut segments: Vec = file_stem.split("/").filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(); if let Some(last) = segments.last() && last == "mod" { @@ -78,11 +68,7 @@ mod tests { // Root level files #[case("users.rs", ".", vec!["users"])] #[case("mod.rs", ".", vec![])] - fn test_file_to_segments( - #[case] file_path: &str, - #[case] base_path: &str, - #[case] expected: Vec<&str>, - ) { + fn test_file_to_segments(#[case] file_path: &str, #[case] base_path: &str, #[case] expected: Vec<&str>) { // Normalize paths by replacing backslashes with forward slashes // This ensures tests work cross-platform (Windows uses \, Unix uses /) let normalized_file_path = file_path.replace("\\", "/"); @@ -91,17 +77,10 @@ mod tests { let base = PathBuf::from(normalized_base_path); let result = file_to_segments(&file, &base); let expected_vec: Vec = expected.iter().map(|s| s.to_string()).collect(); - assert_eq!( - result, expected_vec, - "Failed for file: {}, base: {}", - file_path, base_path - ); + assert_eq!(result, expected_vec, "Failed for file: {}, base: {}", file_path, base_path); } - fn create_test_structure( - temp_dir: &TempDir, - structure: &[(&str, bool)], - ) -> Result<(), std::io::Error> { + fn create_test_structure(temp_dir: &TempDir, structure: &[(&str, bool)]) -> Result<(), std::io::Error> { // (path, is_file) for (path, is_file) in structure { let full_path = temp_dir.path().join(path); @@ -118,15 +97,7 @@ mod tests { } fn normalize_paths(paths: &[PathBuf], base: &Path) -> Vec { - let mut normalized: Vec = paths - .iter() - .map(|p| { - p.strip_prefix(base) - .unwrap_or(p) - .to_string_lossy() - .replace("\\", "/") - }) - .collect(); + let mut normalized: Vec = paths.iter().map(|p| p.strip_prefix(base).unwrap_or(p).to_string_lossy().replace("\\", "/")).collect(); normalized.sort(); normalized } @@ -204,15 +175,10 @@ mod tests { let mut normalized_result = normalize_paths(&result, temp_dir.path()); normalized_result.sort(); - let mut expected_normalized: Vec = - expected_files.iter().map(|s| s.to_string()).collect(); + let mut expected_normalized: Vec = expected_files.iter().map(|s| s.to_string()).collect(); expected_normalized.sort(); - assert_eq!( - normalized_result, expected_normalized, - "Failed for structure: {:?}", - structure - ); + assert_eq!(normalized_result, expected_normalized, "Failed for structure: {:?}", structure); temp_dir.close().expect("Failed to close temp dir"); } diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index e11f644..7b34743 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -30,8 +30,7 @@ pub fn route(_attr: TokenStream, item: TokenStream) -> TokenStream { } // Schema Storage global variable -static SCHEMA_STORAGE: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); +static SCHEMA_STORAGE: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); /// Derive macro for Schema #[proc_macro_derive(Schema)] @@ -41,10 +40,7 @@ pub fn derive_schema(input: TokenStream) -> TokenStream { let generics = &input.generics; let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); - schema_storage.push(StructMetadata { - name: name.to_string(), - definition: quote::quote!(#input).to_string(), - }); + schema_storage.push(StructMetadata { name: name.to_string(), definition: quote::quote!(#input).to_string() }); // Mark both struct and enum as having SchemaBuilder // For generic types, include the generic parameters in the impl @@ -107,13 +103,7 @@ impl Parse for AutoRouterInput { version = Some(input.parse()?); } _ => { - return Err(syn::Error::new( - ident.span(), - format!( - "unknown field: `{}`. Expected `dir` or `openapi`", - ident_str - ), - )); + return Err(syn::Error::new(ident.span(), format!("unknown field: `{}`. Expected `dir` or `openapi`", ident_str))); } } } else if lookahead.peek(syn::LitStr) { @@ -130,38 +120,7 @@ impl Parse for AutoRouterInput { } } - Ok(AutoRouterInput { - dir: dir.or_else(|| { - std::env::var("VESPERA_DIR") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - openapi: openapi.or_else(|| { - std::env::var("VESPERA_OPENAPI") - .map(|f| vec![LitStr::new(&f, Span::call_site())]) - .ok() - }), - title: title.or_else(|| { - std::env::var("VESPERA_TITLE") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - version: version.or_else(|| { - std::env::var("VESPERA_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - docs_url: docs_url.or_else(|| { - std::env::var("VESPERA_DOCS_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - redoc_url: redoc_url.or_else(|| { - std::env::var("VESPERA_REDOC_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - }) + Ok(AutoRouterInput { dir: dir.or_else(|| std::env::var("VESPERA_DIR").map(|f| LitStr::new(&f, Span::call_site())).ok()), openapi: openapi.or_else(|| std::env::var("VESPERA_OPENAPI").map(|f| vec![LitStr::new(&f, Span::call_site())]).ok()), title: title.or_else(|| std::env::var("VESPERA_TITLE").map(|f| LitStr::new(&f, Span::call_site())).ok()), version: version.or_else(|| std::env::var("VESPERA_VERSION").map(|f| LitStr::new(&f, Span::call_site())).ok()), docs_url: docs_url.or_else(|| std::env::var("VESPERA_DOCS_URL").map(|f| LitStr::new(&f, Span::call_site())).ok()), redoc_url: redoc_url.or_else(|| std::env::var("VESPERA_REDOC_URL").map(|f| LitStr::new(&f, Span::call_site())).ok()) }) } } @@ -171,8 +130,7 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result> { if input.peek(syn::token::Bracket) { let content; let _ = bracketed!(content in input); - let entries: Punctuated = - content.parse_terminated(|input| input.parse::(), syn::Token![,])?; + let entries: Punctuated = content.parse_terminated(|input| input.parse::(), syn::Token![,])?; Ok(entries.into_iter().collect()) } else { let single: LitStr = input.parse()?; @@ -184,17 +142,9 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result> { pub fn vespera(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as AutoRouterInput); - let folder_name = input - .dir - .map(|f| f.value()) - .unwrap_or_else(|| "routes".to_string()); + let folder_name = input.dir.map(|f| f.value()).unwrap_or_else(|| "routes".to_string()); - let openapi_file_names = input - .openapi - .unwrap_or_default() - .into_iter() - .map(|f| f.value()) - .collect::>(); + let openapi_file_names = input.openapi.unwrap_or_default().into_iter().map(|f| f.value()).collect::>(); let title = input.title.map(|t| t.value()); let version = input.version.map(|v| v.value()); @@ -204,23 +154,13 @@ pub fn vespera(input: TokenStream) -> TokenStream { let folder_path = find_folder_path(&folder_name); if !folder_path.exists() { - return syn::Error::new( - Span::call_site(), - format!("Folder not found: {}", folder_name), - ) - .to_compile_error() - .into(); + return syn::Error::new(Span::call_site(), format!("Folder not found: {}", folder_name)).to_compile_error().into(); } let mut metadata = match collect_metadata(&folder_path, &folder_name) { Ok(metadata) => metadata, Err(e) => { - return syn::Error::new( - Span::call_site(), - format!("Failed to collect metadata: {}", e), - ) - .to_compile_error() - .into(); + return syn::Error::new(Span::call_site(), format!("Failed to collect metadata: {}", e)).to_compile_error().into(); } }; let schemas = SCHEMA_STORAGE.lock().unwrap().clone(); @@ -234,30 +174,15 @@ pub fn vespera(input: TokenStream) -> TokenStream { // Generate OpenAPI document using collected metadata // Serialize to JSON - let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( - title, version, &metadata, - )) { + let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata(title, version, &metadata)) { Ok(json) => json, Err(e) => { - return syn::Error::new( - Span::call_site(), - format!("Failed to serialize OpenAPI document: {}", e), - ) - .to_compile_error() - .into(); + return syn::Error::new(Span::call_site(), format!("Failed to serialize OpenAPI document: {}", e)).to_compile_error().into(); } }; for openapi_file_name in &openapi_file_names { if let Err(e) = std::fs::write(openapi_file_name, &json_str) { - return syn::Error::new( - Span::call_site(), - format!( - "Failed to write OpenAPI document to {}: {}", - openapi_file_name, e - ), - ) - .to_compile_error() - .into(); + return syn::Error::new(Span::call_site(), format!("Failed to write OpenAPI document to {}: {}", openapi_file_name, e)).to_compile_error().into(); } } if let Some(docs_url) = docs_url { @@ -282,11 +207,7 @@ fn find_folder_path(folder_name: &str) -> std::path::PathBuf { Path::new(folder_name).to_path_buf() } -fn generate_router_code( - metadata: &CollectedMetadata, - docs_info: Option<(String, String)>, - redoc_info: Option<(String, String)>, -) -> proc_macro2::TokenStream { +fn generate_router_code(metadata: &CollectedMetadata, docs_info: Option<(String, String)>, redoc_info: Option<(String, String)>) -> proc_macro2::TokenStream { let mut router_nests = Vec::new(); for route in &metadata.routes { @@ -296,27 +217,9 @@ fn generate_router_code( let module_path = &route.module_path; let function_name = &route.function_name; - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend( - module_path - .split("::") - .filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - }) - .collect::>(), - ); + let mut p: syn::punctuated::Punctuated = syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { ident: syn::Ident::new("crate", Span::call_site()), arguments: syn::PathArguments::None }); + p.extend(module_path.split("::").filter_map(|s| if s.is_empty() { None } else { Some(syn::PathSegment { ident: syn::Ident::new(s, Span::call_site()), arguments: syn::PathArguments::None }) }).collect::>()); let func_name = syn::Ident::new(function_name, Span::call_site()); router_nests.push(quote!( .route(#path, #method_path(#p::#func_name)) @@ -434,25 +337,13 @@ mod tests { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Should generate empty router // quote! generates "vespera :: axum :: Router :: new ()" format - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {}", - code - ); - assert!( - !code.contains("route"), - "Code should not contain route, got: {}", - code - ); + assert!(code.contains("Router") && code.contains("new"), "Code should contain Router::new(), got: {}", code); + assert!(!code.contains("route"), "Code should not contain route, got: {}", code); drop(temp_dir); } @@ -578,59 +469,30 @@ pub fn get_users() -> String { "/api/v1/users", "routes::api::v1::users::get_users", )] - fn test_generate_router_code_single_route( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_path: &str, - ) { + fn test_generate_router_code_single_route(#[case] folder_name: &str, #[case] files: Vec<(&str, &str)>, #[case] expected_method: &str, #[case] expected_path: &str, #[case] expected_function_path: &str) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); for (filename, content) in files { create_temp_file(&temp_dir, filename, content); } - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {}", - code - ); + assert!(code.contains("Router") && code.contains("new"), "Code should contain Router::new(), got: {}", code); // Check route method - assert!( - code.contains(expected_method), - "Code should contain method: {}, got: {}", - expected_method, - code - ); + assert!(code.contains(expected_method), "Code should contain method: {}, got: {}", expected_method, code); // Check route path - assert!( - code.contains(expected_path), - "Code should contain path: {}, got: {}", - expected_path, - code - ); + assert!(code.contains(expected_path), "Code should contain path: {}, got: {}", expected_path, code); // Check function path (quote! adds spaces, so we check for parts) let function_parts: Vec<&str> = expected_function_path.split("::").collect(); for part in &function_parts { if !part.is_empty() { - assert!( - code.contains(part), - "Code should contain function part: {}, got: {}", - part, - code - ); + assert!(code.contains(part), "Code should contain function part: {}, got: {}", part, code); } } @@ -676,11 +538,7 @@ pub fn update_user() -> String { "#, ); - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -699,11 +557,7 @@ pub fn update_user() -> String { // Count route calls (quote! generates ". route (" with spaces) // Count occurrences of ". route (" pattern let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 3, - "Should have 3 route calls, got: {}, code: {}", - route_count, code - ); + assert_eq!(route_count, 3, "Should have 3 route calls, got: {}, code: {}", route_count, code); drop(temp_dir); } @@ -730,11 +584,7 @@ pub fn create_users() -> String { "#, ); - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -750,11 +600,7 @@ pub fn create_users() -> String { // Should have 2 routes (quote! generates ". route (" with spaces) let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 2, - "Should have 2 routes, got: {}, code: {}", - route_count, code - ); + assert_eq!(route_count, 2, "Should have 2 routes, got: {}, code: {}", route_count, code); drop(temp_dir); } @@ -776,11 +622,7 @@ pub fn index() -> String { "#, ); - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -812,11 +654,7 @@ pub fn get_users() -> String { "#, ); - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); + let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index 9606de9..b51019c 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -42,9 +42,6 @@ pub struct CollectedMetadata { impl CollectedMetadata { pub fn new() -> Self { - Self { - routes: Vec::new(), - structs: Vec::new(), - } + Self { routes: Vec::new(), structs: Vec::new() } } } diff --git a/crates/vespera_macro/src/method.rs b/crates/vespera_macro/src/method.rs index 2a538aa..d820ccb 100644 --- a/crates/vespera_macro/src/method.rs +++ b/crates/vespera_macro/src/method.rs @@ -30,74 +30,36 @@ mod tests { #[case(HttpMethod::Head, "head")] #[case(HttpMethod::Options, "options")] #[case(HttpMethod::Trace, "trace")] - fn test_http_method_to_token_stream( - #[case] method: HttpMethod, - #[case] expected_method_name: &str, - ) { + fn test_http_method_to_token_stream(#[case] method: HttpMethod, #[case] expected_method_name: &str) { let result = http_method_to_token_stream(method); let code = result.to_string(); // Check that the code contains the expected method name // quote! generates "vespera :: axum :: routing :: get" format - assert!( - code.contains(expected_method_name), - "Code should contain method name: {}, got: {}", - expected_method_name, - code - ); + assert!(code.contains(expected_method_name), "Code should contain method name: {}, got: {}", expected_method_name, code); // Check that it contains the routing path - assert!( - code.contains("routing"), - "Code should contain 'routing', got: {}", - code - ); + assert!(code.contains("routing"), "Code should contain 'routing', got: {}", code); // Check that it contains the axum path - assert!( - code.contains("axum"), - "Code should contain 'axum', got: {}", - code - ); + assert!(code.contains("axum"), "Code should contain 'axum', got: {}", code); // Check that it contains the vespera path - assert!( - code.contains("vespera"), - "Code should contain 'vespera', got: {}", - code - ); + assert!(code.contains("vespera"), "Code should contain 'vespera', got: {}", code); } #[test] fn test_http_method_to_token_stream_all_methods() { // Test that all methods generate valid TokenStreams - let methods = vec![ - HttpMethod::Get, - HttpMethod::Post, - HttpMethod::Put, - HttpMethod::Patch, - HttpMethod::Delete, - HttpMethod::Head, - HttpMethod::Options, - HttpMethod::Trace, - ]; + let methods = vec![HttpMethod::Get, HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete, HttpMethod::Head, HttpMethod::Options, HttpMethod::Trace]; for method in methods { let result = http_method_to_token_stream(method.clone()); let code = result.to_string(); // Each should generate a valid TokenStream - assert!( - !code.is_empty(), - "TokenStream should not be empty for {:?}", - method - ); - assert!( - code.contains("routing"), - "TokenStream should contain 'routing' for {:?}, got: {}", - method, - code - ); + assert!(!code.is_empty(), "TokenStream should not be empty for {:?}", method); + assert!(code.contains("routing"), "TokenStream should contain 'routing' for {:?}, got: {}", method, code); } } } diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index b371216..e722af4 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -11,17 +11,11 @@ use crate::metadata::CollectedMetadata; use crate::parser::{build_operation_from_function, parse_enum_to_schema, parse_struct_to_schema}; /// Generate OpenAPI document from collected metadata -pub fn generate_openapi_doc_with_metadata( - title: Option, - version: Option, - metadata: &CollectedMetadata, -) -> OpenApi { +pub fn generate_openapi_doc_with_metadata(title: Option, version: Option, metadata: &CollectedMetadata) -> OpenApi { let mut paths: BTreeMap = BTreeMap::new(); 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(); + 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 and store struct definitions for struct_meta in &metadata.structs { @@ -34,19 +28,11 @@ pub fn generate_openapi_doc_with_metadata( for struct_meta in &metadata.structs { 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, &struct_definitions) - } - syn::Item::Enum(enum_item) => { - parse_enum_to_schema(&enum_item, &known_schema_names, &struct_definitions) - } + syn::Item::Struct(struct_item) => 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), _ => { // Fallback to struct parsing for backward compatibility - parse_struct_to_schema( - &syn::parse_str(&struct_meta.definition).unwrap(), - &known_schema_names, - &struct_definitions, - ) + parse_struct_to_schema(&syn::parse_str(&struct_meta.definition).unwrap(), &known_schema_names, &struct_definitions) } }; let schema_name = struct_meta.name.clone(); @@ -59,10 +45,7 @@ pub fn generate_openapi_doc_with_metadata( let content = match std::fs::read_to_string(&route_meta.file_path) { Ok(content) => content, Err(e) => { - eprintln!( - "Warning: Failed to read file {}: {}", - route_meta.file_path, e - ); + eprintln!("Warning: Failed to read file {}: {}", route_meta.file_path, e); continue; } }; @@ -70,10 +53,7 @@ pub fn generate_openapi_doc_with_metadata( let file_ast = match syn::parse_file(&content) { Ok(ast) => ast, Err(e) => { - eprintln!( - "Warning: Failed to parse file {}: {}", - route_meta.file_path, e - ); + eprintln!("Warning: Failed to parse file {}: {}", route_meta.file_path, e); continue; } }; @@ -85,30 +65,10 @@ pub fn generate_openapi_doc_with_metadata( let method = HttpMethod::from(route_meta.method.as_str()); // Build operation from function signature - let operation = build_operation_from_function( - &fn_item.sig, - &route_meta.path, - &known_schema_names, - &struct_definitions, - route_meta.error_status.as_deref(), - ); + let operation = build_operation_from_function(&fn_item.sig, &route_meta.path, &known_schema_names, &struct_definitions, route_meta.error_status.as_deref()); // Get or create PathItem - let path_item = paths - .entry(route_meta.path.clone()) - .or_insert_with(|| PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }); + let path_item = paths.entry(route_meta.path.clone()).or_insert_with(|| PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }); // Set operation for the method path_item.set_operation(method, operation); @@ -118,40 +78,7 @@ pub fn generate_openapi_doc_with_metadata( } // Build OpenAPI document - OpenApi { - openapi: OpenApiVersion::V3_1_0, - info: Info { - title: title.unwrap_or_else(|| "API".to_string()), - version: version.unwrap_or_else(|| "1.0.0".to_string()), - description: None, - terms_of_service: None, - contact: None, - license: None, - summary: None, - }, - servers: Some(vec![Server { - url: "http://localhost:3000".to_string(), - description: None, - variables: None, - }]), - paths, - components: Some(Components { - schemas: if schemas.is_empty() { - None - } else { - Some(schemas) - }, - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: None, - }), - security: None, - tags: None, - external_docs: None, - } + OpenApi { openapi: OpenApiVersion::V3_1_0, info: Info { title: title.unwrap_or_else(|| "API".to_string()), version: version.unwrap_or_else(|| "1.0.0".to_string()), description: None, terms_of_service: None, contact: None, license: None, summary: None }, servers: Some(vec![Server { url: "http://localhost:3000".to_string(), description: None, variables: None }]), paths, components: Some(Components { schemas: if schemas.is_empty() { None } else { Some(schemas) }, responses: None, parameters: None, examples: None, request_bodies: None, headers: None, security_schemes: None }), security: None, tags: None, external_docs: None } } #[cfg(test)] @@ -181,10 +108,7 @@ mod tests { assert!(doc.paths.is_empty()); assert!(doc.components.as_ref().unwrap().schemas.is_none()); assert_eq!(doc.servers.as_ref().unwrap().len(), 1); - assert_eq!( - doc.servers.as_ref().unwrap()[0].url, - "http://localhost:3000" - ); + assert_eq!(doc.servers.as_ref().unwrap()[0].url, "http://localhost:3000"); } #[rstest] @@ -192,12 +116,7 @@ mod tests { #[case(Some("My API".to_string()), None, "My API", "1.0.0")] #[case(None, Some("2.0.0".to_string()), "API", "2.0.0")] #[case(Some("Test API".to_string()), Some("3.0.0".to_string()), "Test API", "3.0.0")] - fn test_generate_openapi_title_version( - #[case] title: Option, - #[case] version: Option, - #[case] expected_title: &str, - #[case] expected_version: &str, - ) { + fn test_generate_openapi_title_version(#[case] title: Option, #[case] version: Option, #[case] expected_title: &str, #[case] expected_version: &str) { let metadata = CollectedMetadata::new(); let doc = generate_openapi_doc_with_metadata(title, version, &metadata); @@ -219,15 +138,7 @@ pub fn get_users() -> String { let route_file = create_temp_file(&temp_dir, "users.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - }); + metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/users".to_string(), function_name: "get_users".to_string(), module_path: "test::users".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_users() -> String".to_string(), error_status: None }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -241,10 +152,7 @@ pub fn get_users() -> String { #[test] fn test_generate_openapi_with_struct() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - }); + metadata.structs.push(StructMetadata { name: "User".to_string(), definition: "struct User { id: i32, name: String }".to_string() }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -256,10 +164,7 @@ pub fn get_users() -> String { #[test] fn test_generate_openapi_with_enum() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive, Pending }".to_string(), - }); + metadata.structs.push(StructMetadata { name: "Status".to_string(), definition: "enum Status { Active, Inactive, Pending }".to_string() }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -272,10 +177,7 @@ pub fn get_users() -> String { fn test_generate_openapi_with_enum_with_data() { // Test enum with data (tuple and struct variants) to ensure full coverage let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Message".to_string(), - definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), - }); + metadata.structs.push(StructMetadata { name: "Message".to_string(), definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string() }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -296,19 +198,8 @@ pub fn get_status() -> Status { let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive }".to_string(), - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/status".to_string(), - function_name: "get_status".to_string(), - module_path: "test::status_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_status() -> Status".to_string(), - error_status: None, - }); + metadata.structs.push(StructMetadata { name: "Status".to_string(), definition: "enum Status { Active, Inactive }".to_string() }); + metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/status".to_string(), function_name: "get_status".to_string(), module_path: "test::status_route".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_status() -> Status".to_string(), error_status: None }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -353,25 +244,10 @@ pub fn get_user() -> User { let route_file = create_temp_file(&temp_dir, "user_route.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - }); + metadata.structs.push(StructMetadata { name: "User".to_string(), definition: "struct User { id: i32, name: String }".to_string() }); + metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/user".to_string(), function_name: "get_user".to_string(), module_path: "test::user_route".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_user() -> User".to_string(), error_status: None }); - let doc = generate_openapi_doc_with_metadata( - Some("Test API".to_string()), - Some("1.0.0".to_string()), - &metadata, - ); + let doc = generate_openapi_doc_with_metadata(Some("Test API".to_string()), Some("1.0.0".to_string()), &metadata); // Check struct schema assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -403,24 +279,8 @@ pub fn create_user() -> String { let route2_file = create_temp_file(&temp_dir, "create_user.rs", route2_content); let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route1_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - }); - metadata.routes.push(RouteMetadata { - method: "POST".to_string(), - path: "/users".to_string(), - function_name: "create_user".to_string(), - module_path: "test::create_user".to_string(), - file_path: route2_file.to_string_lossy().to_string(), - signature: "fn create_user() -> String".to_string(), - error_status: None, - }); + metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/users".to_string(), function_name: "get_users".to_string(), module_path: "test::users".to_string(), file_path: route1_file.to_string_lossy().to_string(), signature: "fn get_users() -> String".to_string(), error_status: None }); + metadata.routes.push(RouteMetadata { method: "POST".to_string(), path: "/users".to_string(), function_name: "create_user".to_string(), module_path: "test::create_user".to_string(), file_path: route2_file.to_string_lossy().to_string(), signature: "fn create_user() -> String".to_string(), error_status: None }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -460,12 +320,7 @@ pub fn create_user() -> String { false, // struct should not be added false, // route should not be added )] - fn test_generate_openapi_file_errors( - #[case] struct_meta: Option, - #[case] route_meta: Option, - #[case] expect_struct: bool, - #[case] expect_route: bool, - ) { + fn test_generate_openapi_file_errors(#[case] struct_meta: Option, #[case] route_meta: Option, #[case] expect_struct: bool, #[case] expect_route: bool) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let mut metadata = CollectedMetadata::new(); @@ -479,8 +334,7 @@ pub fn create_user() -> String { if let Some(mut route_m) = route_meta { // If file_path is empty, create invalid syntax file if route_m.file_path.is_empty() { - let invalid_file = - create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); + let invalid_file = create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); route_m.file_path = invalid_file.to_string_lossy().to_string(); } metadata.routes.push(route_m); diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 38369ce..79f1d38 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -6,20 +6,10 @@ use vespera_core::{ schema::{Schema, SchemaRef}, }; -use super::{ - parameters::parse_function_parameter, path::extract_path_parameters, - request_body::parse_request_body, response::parse_return_type, - schema::parse_type_to_schema_ref_with_schemas, -}; +use super::{parameters::parse_function_parameter, path::extract_path_parameters, request_body::parse_request_body, response::parse_return_type, schema::parse_type_to_schema_ref_with_schemas}; /// Build Operation from function signature -pub fn build_operation_from_function( - sig: &syn::Signature, - path: &str, - known_schemas: &std::collections::HashMap, - struct_definitions: &std::collections::HashMap, - error_status: Option<&[u16]>, -) -> Operation { +pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_schemas: &std::collections::HashMap, struct_definitions: &std::collections::HashMap, error_status: Option<&[u16]>) -> Operation { let path_params = extract_path_parameters(path); let mut parameters = Vec::new(); let mut request_body = None; @@ -53,82 +43,27 @@ pub fn build_operation_from_function( // For tuple types, match each path parameter with tuple element type for (idx, param_name) in path_params.iter().enumerate() { if let Some(elem_ty) = tuple.elems.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - )), - example: None, - }); + parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(elem_ty, known_schemas, struct_definitions)), example: None }); } else { // If tuple doesn't have enough elements, use String as default - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &syn::parse_str::("String").unwrap(), - known_schemas, - struct_definitions, - )), - example: None, - }); + parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&syn::parse_str::("String").unwrap(), known_schemas, struct_definitions)), example: None }); } } } else { // Single path parameter if path_params.len() == 1 { - parameters.push(Parameter { - name: path_params[0].clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &ty, - known_schemas, - struct_definitions, - )), - example: None, - }); + parameters.push(Parameter { name: path_params[0].clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&ty, known_schemas, struct_definitions)), example: None }); } else { // Multiple path parameters but single type - use String for all for param_name in &path_params { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &ty, - known_schemas, - struct_definitions, - )), - example: None, - }); + parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&ty, known_schemas, struct_definitions)), example: None }); } } } } else { // No Path extractor found, but path has parameters - use String as default for param_name in &path_params { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - &syn::parse_str::("String").unwrap(), - known_schemas, - struct_definitions, - )), - example: None, - }); + parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&syn::parse_str::("String").unwrap(), known_schemas, struct_definitions)), example: None }); } } } @@ -150,10 +85,7 @@ pub fn build_operation_from_function( false }; - if !is_path_extractor - && let Some(params) = - parse_function_parameter(input, &path_params, known_schemas, struct_definitions) - { + if !is_path_extractor && let Some(params) = parse_function_parameter(input, &path_params, known_schemas, struct_definitions) { parameters.extend(params); } } @@ -164,19 +96,10 @@ pub fn build_operation_from_function( && let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { let is_string = match ty.as_ref() { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false), + Type::Path(type_path) => type_path.path.segments.last().map(|s| s.ident == "String" || s.ident == "str").unwrap_or(false), Type::Reference(type_ref) => { if let Type::Path(p) = type_ref.elem.as_ref() { - p.path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false) + p.path.segments.last().map(|s| s.ident == "String" || s.ident == "str").unwrap_or(false) } else { false } @@ -186,19 +109,8 @@ pub fn build_operation_from_function( if is_string { let mut content = BTreeMap::new(); - content.insert( - "text/plain".to_string(), - MediaType { - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - examples: None, - }, - ); - request_body = Some(RequestBody { - description: None, - content, - required: Some(true), - }); + content.insert("text/plain".to_string(), MediaType { schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None, examples: None }); + request_body = Some(RequestBody { description: None, content, required: Some(true) }); } } @@ -208,16 +120,7 @@ pub fn build_operation_from_function( // Add additional error status codes from error_status attribute if let Some(status_codes) = error_status { // Find the error response schema (usually 400 or the first error response) - let error_schema = responses - .iter() - .find(|(code, _)| code != &&"200".to_string()) - .and_then(|(_, resp)| { - resp.content - .as_ref()? - .get("application/json")? - .schema - .clone() - }); + let error_schema = responses.iter().find(|(code, _)| code != &&"200".to_string()).and_then(|(_, resp)| resp.content.as_ref()?.get("application/json")?.schema.clone()); if let Some(schema) = error_schema { for &status_code in status_codes { @@ -225,39 +128,15 @@ pub fn build_operation_from_function( // Only add if not already present responses.entry(status_str).or_insert_with(|| { let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(schema.clone()), - example: None, - examples: None, - }, - ); + err_content.insert("application/json".to_string(), MediaType { schema: Some(schema.clone()), example: None, examples: None }); - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - } + Response { description: "Error response".to_string(), headers: None, content: Some(err_content) } }); } } } - Operation { - operation_id: Some(sig.ident.to_string()), - tags: None, - summary: None, - description: None, - parameters: if parameters.is_empty() { - None - } else { - Some(parameters) - }, - request_body, - responses, - security: None, - } + Operation { operation_id: Some(sig.ident.to_string()), tags: None, summary: None, description: None, parameters: if parameters.is_empty() { None } else { Some(parameters) }, request_body, responses, security: None } } #[cfg(test)] @@ -269,8 +148,7 @@ mod tests { #[test] fn test_build_operation_string_body_fallback() { let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); - let op = - build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); + let op = build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); // Ensure body is set as text/plain let body = op.request_body.as_ref().expect("request body expected"); diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 9e43919..be2dab9 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -6,20 +6,12 @@ use vespera_core::{ schema::{Schema, SchemaRef, SchemaType}, }; -use super::schema::{ - extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, - parse_type_to_schema_ref_with_schemas, rename_field, -}; +use super::schema::{extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, parse_type_to_schema_ref_with_schemas, rename_field}; /// 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> { +pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schemas: &HashMap, struct_definitions: &HashMap) -> Option> { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { pat, ty, .. }) => { @@ -58,14 +50,7 @@ pub fn parse_function_parameter( if inner_ident_str == "TypedHeader" { // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace("_", "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(false), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); + return Some(vec![Parameter { name: param_name.replace("_", "-"), r#in: ParameterLocation::Header, description: None, required: Some(false), schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None }]); } } } @@ -84,8 +69,7 @@ pub fn parse_function_parameter( "Path" => { // Path extractor - use path parameter name from route if available if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { // Check if inner type is a tuple (e.g., Path<(String, String, String)>) if let Type::Tuple(tuple) = inner_ty { @@ -96,20 +80,7 @@ pub fn parse_function_parameter( // Match tuple elements with path parameters for (idx, elem_ty) in tuple_elems.iter().enumerate() { if let Some(param_name) = path_params.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some( - parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - ), - ), - example: None, - }); + parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(elem_ty, known_schemas, struct_definitions)), example: None }); } } @@ -123,26 +94,14 @@ pub fn parse_function_parameter( return None; } let name = path_params[0].clone(); - return Some(vec![Parameter { - name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + return Some(vec![Parameter { name, r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); } } } "Query" => { // Query extractor if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { // Check if it's HashMap or BTreeMap - ignore these if is_map_type(inner_ty) { @@ -150,11 +109,7 @@ pub fn parse_function_parameter( } // 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, - ) { + if let Some(struct_params) = parse_query_struct_to_parameters(inner_ty, known_schemas, struct_definitions) { return Some(struct_params); } @@ -170,55 +125,25 @@ pub fn parse_function_parameter( } // Otherwise, treat as single parameter - return Some(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Query, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Query, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); } } "Header" => { // Header extractor if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { // Ignore primitive-like headers if is_primitive_like(inner_ty) { return None; } - return Some(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Header, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); } } "TypedHeader" => { // TypedHeader extractor (axum::TypedHeader) // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace("_", "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); + return Some(vec![Parameter { name: param_name.replace("_", "-"), r#in: ParameterLocation::Header, description: None, required: Some(true), schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None }]); } "Json" => { // Json extractor - this will be handled as RequestBody @@ -231,18 +156,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(vec![Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions)), example: None }]); } // Bare primitive without extractor is ignored (cannot infer location) @@ -282,11 +196,7 @@ fn is_primitive_like(ty: &Type) -> bool { false } -fn is_known_type( - ty: &Type, - known_schemas: &HashMap, - struct_definitions: &HashMap, -) -> bool { +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; @@ -327,11 +237,7 @@ fn is_known_type( /// 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> { +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; @@ -355,11 +261,7 @@ fn parse_query_struct_to_parameters( 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()); + 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) { @@ -385,28 +287,18 @@ fn parse_query_struct_to_parameters( // 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, - ); + 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(type_name) = ref_ref.ref_path.strip_prefix("#/components/schemas/") && let Some(struct_def) = struct_definitions.get(type_name) - && let Ok(nested_struct_item) = - syn::parse_str::(struct_def) + && 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, - ); + let nested_schema = parse_struct_to_schema(&nested_struct_item, known_schemas, struct_definitions); field_schema = SchemaRef::Inline(Box::new(nested_schema)); } } @@ -418,32 +310,19 @@ fn parse_query_struct_to_parameters( 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() - })) + 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::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, - }); + parameters.push(Parameter { name: field_name, r#in: ParameterLocation::Query, description: None, required: Some(required), schema: Some(final_schema), example: None }); } } @@ -594,39 +473,23 @@ mod tests { vec![vec![ParameterLocation::Header]], "header_custom" )] - fn test_parse_function_parameter_cases( - #[case] func_src: &str, - #[case] path_params: Vec, - #[case] expected_locations: Vec>, - #[case] suffix: &str, - ) { + fn test_parse_function_parameter_cases(#[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, #[case] suffix: &str) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); let mut parameters = Vec::new(); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = - parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); - let expected = expected_locations - .get(idx) - .unwrap_or_else(|| expected_locations.last().unwrap()); + let result = parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + let expected = expected_locations.get(idx).unwrap_or_else(|| expected_locations.last().unwrap()); if expected.is_empty() { - assert!( - result.is_none(), - "Expected None at arg index {}, func: {}", - idx, - func_src - ); + assert!(result.is_none(), "Expected None at arg index {}, func: {}", idx, func_src); continue; } let params = result.as_ref().expect("Expected Some parameters"); let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); - assert_eq!( - got_locs, *expected, - "Location mismatch at arg index {idx}, func: {func_src}" - ); + assert_eq!(got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}"); parameters.extend(params.clone()); } with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { @@ -663,35 +526,19 @@ mod tests { "fn test((x, y): (i32, i32)) {}", vec![], )] - fn test_parse_function_parameter_wrong_cases( - #[case] func_src: &str, - #[case] path_params: Vec, - ) { + fn test_parse_function_parameter_wrong_cases(#[case] func_src: &str, #[case] path_params: Vec) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); // Provide custom types for header/query known schemas/structs let mut struct_definitions = struct_definitions; - struct_definitions.insert( - "User".to_string(), - "pub struct User { pub id: i32 }".to_string(), - ); + struct_definitions.insert("User".to_string(), "pub struct User { pub id: i32 }".to_string()); let mut known_schemas = known_schemas; - known_schemas.insert( - "CustomHeader".to_string(), - "#/components/schemas/CustomHeader".to_string(), - ); + known_schemas.insert("CustomHeader".to_string(), "#/components/schemas/CustomHeader".to_string()); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = - parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); - assert!( - result.is_none(), - "Expected None at arg index {}, func: {}, got: {:?}", - idx, - func_src, - result - ); + let result = parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + assert!(result.is_none(), "Expected None at arg index {}, func: {}, got: {:?}", idx, func_src, result); } } @@ -740,19 +587,9 @@ mod tests { #[case("Vec", HashMap::new(), HashMap::new(), true)] // Vec with known inner type #[case("Option", HashMap::new(), HashMap::new(), true)] // Option with known inner type #[case("UnknownType", HashMap::new(), HashMap::new(), false)] // unknown type - fn test_is_known_type( - #[case] type_str: &str, - #[case] known_schemas: HashMap, - #[case] struct_definitions: HashMap, - #[case] expected: bool, - ) { + fn test_is_known_type(#[case] type_str: &str, #[case] known_schemas: HashMap, #[case] struct_definitions: HashMap, #[case] expected: bool) { let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!( - is_known_type(&ty, &known_schemas, &struct_definitions), - expected, - "Type: {}", - type_str - ); + assert_eq!(is_known_type(&ty, &known_schemas, &struct_definitions), expected, "Type: {}", type_str); } #[test] diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index f411be8..5a87162 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -7,23 +7,14 @@ use super::schema::parse_type_to_schema_ref_with_schemas; fn is_string_like(ty: &Type) -> bool { match ty { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|seg| seg.ident == "String" || seg.ident == "str") - .unwrap_or(false), + Type::Path(type_path) => type_path.path.segments.last().map(|seg| seg.ident == "String" || seg.ident == "str").unwrap_or(false), Type::Reference(type_ref) => is_string_like(&type_ref.elem), _ => false, } } /// Analyze function signature and extract RequestBody -pub fn parse_request_body( - arg: &FnArg, - known_schemas: &std::collections::HashMap, - struct_definitions: &std::collections::HashMap, -) -> Option { +pub fn parse_request_body(arg: &FnArg, known_schemas: &std::collections::HashMap, struct_definitions: &std::collections::HashMap) -> Option { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { ty, .. }) => { @@ -41,46 +32,19 @@ 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_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - ); + 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(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); - return Some(RequestBody { - description: None, - required: Some(true), - content, - }); + content.insert("application/json".to_string(), MediaType { schema: Some(schema), example: None, examples: None }); + return Some(RequestBody { description: None, required: Some(true), content }); } } if is_string_like(ty.as_ref()) { - let schema = - parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); + let schema = parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); let mut content = BTreeMap::new(); - content.insert( - "text/plain".to_string(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); + content.insert("text/plain".to_string(), MediaType { schema: Some(schema), example: None, examples: None }); - return Some(RequestBody { - description: None, - required: Some(true), - content, - }); + return Some(RequestBody { description: None, required: Some(true), content }); } None } @@ -100,11 +64,7 @@ mod tests { #[case::string("fn test(just_string: String) {}", true, "string")] #[case::str("fn test(just_str: &str) {}", true, "str")] #[case::i32("fn test(just_i32: i32) {}", false, "i32")] - fn test_parse_request_body_cases( - #[case] func_src: &str, - #[case] has_body: bool, - #[case] suffix: &str, - ) { + fn test_parse_request_body_cases(#[case] func_src: &str, #[case] has_body: bool, #[case] suffix: &str) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let arg = func.sig.inputs.first().unwrap(); let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); @@ -118,13 +78,9 @@ mod tests { fn test_parse_request_body_text_plain_schema() { let func: syn::ItemFn = syn::parse_str("fn test(body: &str) {}").unwrap(); let arg = func.sig.inputs.first().unwrap(); - let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()) - .expect("expected request body"); + let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()).expect("expected request body"); - let media = body - .content - .get("text/plain") - .expect("expected text/plain content"); + let media = body.content.get("text/plain").expect("expected text/plain content"); if let SchemaRef::Inline(schema) = media.schema.as_ref().expect("schema expected") { assert_eq!(schema.schema_type, Some(SchemaType::String)); diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 088327d..3de9bb5 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -57,10 +57,7 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { 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)) + && 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); @@ -81,8 +78,7 @@ fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { let path = &type_path.path; 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")); + 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 @@ -117,11 +113,7 @@ fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option (Type, Option, - struct_definitions: &HashMap, -) -> BTreeMap { +pub fn parse_return_type(return_type: &ReturnType, known_schemas: &HashMap, struct_definitions: &HashMap) -> BTreeMap { let mut responses = BTreeMap::new(); match return_type { ReturnType::Default => { // No return type - just 200 with no content - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: None, - content: None, - }, - ); + responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: None, content: None }); } ReturnType::Type(_, ty) => { // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); - let ok_schema = parse_type_to_schema_ref_with_schemas( - &ok_payload_ty, - known_schemas, - struct_definitions, - ); + let ok_schema = parse_type_to_schema_ref_with_schemas(&ok_payload_ty, known_schemas, struct_definitions); let mut ok_content = BTreeMap::new(); - ok_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(ok_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: ok_headers, - content: Some(ok_content), - }, - ); + ok_content.insert("application/json".to_string(), MediaType { schema: Some(ok_schema), example: None, examples: None }); + + responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: ok_headers, content: Some(ok_content) }); // Handle error response // 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_with_schemas( - &error_type, - known_schemas, - struct_definitions, - ); + 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(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - status_code.to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); + err_content.insert("application/json".to_string(), MediaType { schema: Some(err_schema), example: None, examples: None }); + + responses.insert(status_code.to_string(), Response { description: "Error response".to_string(), headers: None, content: Some(err_content) }); } else { // 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_with_schemas( - err_ty_unwrapped, - known_schemas, - struct_definitions, - ); + 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(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "400".to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); + err_content.insert("application/json".to_string(), MediaType { schema: Some(err_schema), example: None, examples: None }); + + responses.insert("400".to_string(), Response { description: "Error response".to_string(), headers: None, content: Some(err_content) }); } } else { // Not a Result type - regular response // Unwrap Json if present let unwrapped_ty = unwrap_json(ty); - let schema = parse_type_to_schema_ref_with_schemas( - unwrapped_ty, - known_schemas, - struct_definitions, - ); + 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(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: None, - content: Some(content), - }, - ); + content.insert("application/json".to_string(), MediaType { schema: Some(schema), example: None, examples: None }); + + responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: None, content: Some(content) }); } } } @@ -284,10 +193,7 @@ mod tests { #[case("-> Result", "Result")] // Result with same types #[case("-> Result", "Result")] // Result with different types #[case("-> Result, String>", "Result, String>")] // Result with Json wrapper - #[case( - "-> Result", - "Result" - )] // Result with status code tuple + #[case("-> Result", "Result")] // Result with status code tuple #[case("-> &str", "&str")] // Reference return type #[case("-> Result<&str, String>", "Result<&str, String>")] // Result with reference fn test_parse_return_type(#[case] return_type_str: &str, #[case] expected_type: &str) { @@ -299,8 +205,7 @@ mod tests { } else { // Parse the return type from string 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 parsed: syn::Signature = syn::parse_str(&full_signature).expect("Failed to parse return type"); parsed.output }; diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 6dcaca7..196a22d 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -131,8 +131,7 @@ pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String Some("SCREAMING_SNAKE_CASE") => { // Convert to SCREAMING_SNAKE_CASE // If already in SCREAMING_SNAKE_CASE format, return as is - if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') - { + if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') { return field_name.to_string(); } // First convert to snake_case if needed, then uppercase @@ -154,11 +153,7 @@ pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String // First convert to kebab-case if needed, then uppercase let mut kebab_case = String::new(); for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() - && i > 0 - && !kebab_case.ends_with('-') - && !kebab_case.ends_with('_') - { + if ch.is_uppercase() && i > 0 && !kebab_case.ends_with('-') && !kebab_case.ends_with('_') { kebab_case.push('-'); } if ch == '_' { @@ -175,19 +170,12 @@ pub(super) 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 { +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); // Check if all variants are unit variants - let all_unit = enum_item - .variants - .iter() - .all(|v| matches!(v.fields, syn::Fields::Unit)); + let all_unit = enum_item.variants.iter().all(|v| matches!(v.fields, syn::Fields::Unit)); if all_unit { // Simple enum with string values @@ -207,15 +195,7 @@ pub fn parse_enum_to_schema( enum_values.push(serde_json::Value::String(enum_value)); } - Schema { - schema_type: Some(SchemaType::String), - r#enum: if enum_values.is_empty() { - None - } else { - Some(enum_values) - }, - ..Schema::string() - } + Schema { schema_type: Some(SchemaType::String), r#enum: if enum_values.is_empty() { None } else { Some(enum_values) }, ..Schema::string() } } else { // Enum with data - use oneOf let mut one_of_schemas = Vec::new(); @@ -234,11 +214,7 @@ pub fn parse_enum_to_schema( let variant_schema = match &variant.fields { syn::Fields::Unit => { // Unit variant: {"const": "VariantName"} - Schema { - schema_type: Some(SchemaType::String), - r#enum: Some(vec![serde_json::Value::String(variant_key)]), - ..Schema::string() - } + Schema { r#enum: Some(vec![serde_json::Value::String(variant_key)]), ..Schema::string() } } syn::Fields::Unnamed(fields_unnamed) => { // Tuple variant: {"VariantName": } @@ -247,29 +223,19 @@ 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, struct_definitions); + 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); - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } + Schema { properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() } } else { // Multiple fields tuple variant - serialize as array // serde serializes tuple variants as: {"VariantName": [value1, value2, ...]} // 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); } @@ -277,7 +243,6 @@ pub fn parse_enum_to_schema( // Create array schema with prefixItems for tuple arrays (OpenAPI 3.1) let array_schema = Schema { - schema_type: Some(SchemaType::Array), prefix_items: Some(tuple_item_schemas), min_items: Some(tuple_len), max_items: Some(tuple_len), @@ -286,17 +251,9 @@ pub fn parse_enum_to_schema( }; let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(array_schema)), - ); + properties.insert(variant_key.clone(), SchemaRef::Inline(Box::new(array_schema))); - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } + Schema { properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() } } } syn::Fields::Named(fields_named) => { @@ -306,26 +263,18 @@ pub fn parse_enum_to_schema( let variant_rename_all = extract_rename_all(&variant.attrs); 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()); + 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, - variant_rename_all.as_deref().or(rename_all.as_deref()), - ) + rename_field(&rust_field_name, variant_rename_all.as_deref().or(rename_all.as_deref())) }; let field_type = &field.ty; - let schema_ref = - 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); variant_properties.insert(field_name.clone(), schema_ref); @@ -347,33 +296,12 @@ pub fn parse_enum_to_schema( } // Wrap struct variant in an object with the variant name as key - let inner_struct_schema = Schema { - schema_type: Some(SchemaType::Object), - properties: if variant_properties.is_empty() { - None - } else { - Some(variant_properties) - }, - required: if variant_required.is_empty() { - None - } else { - Some(variant_required) - }, - ..Schema::object() - }; + let inner_struct_schema = Schema { properties: if variant_properties.is_empty() { None } else { Some(variant_properties) }, required: if variant_required.is_empty() { None } else { Some(variant_required) }, ..Schema::object() }; let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(inner_struct_schema)), - ); - - Schema { - schema_type: Some(SchemaType::Object), - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } + properties.insert(variant_key.clone(), SchemaRef::Inline(Box::new(inner_struct_schema))); + + Schema { properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() } } }; @@ -382,21 +310,13 @@ pub fn parse_enum_to_schema( Schema { schema_type: None, // oneOf doesn't have a single type - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, + one_of: if one_of_schemas.is_empty() { None } else { Some(one_of_schemas) }, ..Schema::new(SchemaType::Object) } } } -pub fn parse_struct_to_schema( - struct_item: &syn::ItemStruct, - known_schemas: &HashMap, - struct_definitions: &HashMap, -) -> 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(); @@ -406,11 +326,7 @@ pub fn parse_struct_to_schema( 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()); + 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) { @@ -422,8 +338,7 @@ pub fn parse_struct_to_schema( let field_type = &field.ty; - let schema_ref = - 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); @@ -452,20 +367,7 @@ pub fn parse_struct_to_schema( } } - 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() - } + 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() } } fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type { @@ -506,21 +408,7 @@ pub(super) fn is_primitive_type(ty: &Type) -> bool { let path = &type_path.path; if path.segments.len() == 1 { let ident = path.segments[0].ident.to_string(); - matches!( - ident.as_str(), - "i8" | "i16" - | "i32" - | "i64" - | "u8" - | "u16" - | "u32" - | "u64" - | "f32" - | "f64" - | "bool" - | "String" - | "str" - ) + matches!(ident.as_str(), "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64" | "bool" | "String" | "str") } else { false } @@ -529,19 +417,11 @@ pub(super) fn is_primitive_type(ty: &Type) -> bool { } } -pub fn parse_type_to_schema_ref( - ty: &Type, - known_schemas: &HashMap, - struct_definitions: &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) } -pub(super) fn parse_type_to_schema_ref_with_schemas( - ty: &Type, - known_schemas: &HashMap, - struct_definitions: &HashMap, -) -> SchemaRef { +pub(super) 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; @@ -558,11 +438,7 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( 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 { @@ -578,30 +454,17 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // HashMap or BTreeMap -> object with additionalProperties // K is typically String, we use V as the value type if args.args.len() >= 2 - && let ( - Some(syn::GenericArgument::Type(_key_ty)), - Some(syn::GenericArgument::Type(value_ty)), - ) = (args.args.get(0), args.args.get(1)) + && let (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, - ); + 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!({})) - } + 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() - })); + return SchemaRef::Inline(Box::new(Schema { schema_type: Some(SchemaType::Object), additional_properties: Some(additional_props_value), ..Schema::object() })); } } _ => {} @@ -610,9 +473,7 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // Handle primitive types match ident_str.as_str() { - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { - SchemaRef::Inline(Box::new(Schema::integer())) - } + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => SchemaRef::Inline(Box::new(Schema::integer())), "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), @@ -641,41 +502,16 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( && 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(); + 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(); + 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, - ); + field.ty = substitute_type(&field.ty, &generic_params, &concrete_types); } } @@ -684,11 +520,7 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( parsed.generics.where_clause = None; // Parse the substituted struct to schema (inline) - let schema = parse_struct_to_schema( - &parsed, - known_schemas, - struct_definitions, - ); + let schema = parse_struct_to_schema(&parsed, known_schemas, struct_definitions); return SchemaRef::Inline(Box::new(schema)); } } @@ -722,11 +554,7 @@ mod tests { #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] #[case("Option", Some(SchemaType::String), false)] // nullable check - fn test_parse_type_to_schema_ref_cases( - #[case] ty_src: &str, - #[case] expected_type: Option, - #[case] expect_additional_props: bool, - ) { + fn test_parse_type_to_schema_ref_cases(#[case] ty_src: &str, #[case] expected_type: Option, #[case] expect_additional_props: bool) { let ty: syn::Type = syn::parse_str(ty_src).unwrap(); let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); if let SchemaRef::Inline(schema) = schema_ref { @@ -779,22 +607,11 @@ mod tests { vec!["first_item", "second_item"], "simple_snake" )] - fn test_parse_enum_to_schema_unit_variants( - #[case] enum_src: &str, - #[case] expected_type: SchemaType, - #[case] expected_enum: Vec<&str>, - #[case] suffix: &str, - ) { + fn test_parse_enum_to_schema_unit_variants(#[case] enum_src: &str, #[case] expected_type: SchemaType, #[case] expected_enum: Vec<&str>, #[case] suffix: &str) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); assert_eq!(schema.schema_type, Some(expected_type)); - let got = schema - .clone() - .r#enum - .unwrap() - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect::>(); + let got = schema.clone().r#enum.unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); assert_eq!(got, expected_enum); with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { assert_debug_snapshot!(schema); @@ -835,13 +652,7 @@ mod tests { 0, // not an array; ignore prefix_items length "named_object" )] - fn test_parse_enum_to_schema_tuple_and_named_variants( - #[case] enum_src: &str, - #[case] expected_one_of_len: usize, - #[case] expected_inner_type: Option, - #[case] expected_prefix_items_len: usize, - #[case] suffix: &str, - ) { + fn test_parse_enum_to_schema_tuple_and_named_variants(#[case] enum_src: &str, #[case] expected_one_of_len: usize, #[case] expected_inner_type: Option, #[case] expected_prefix_items_len: usize, #[case] suffix: &str) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); let one_of = schema.clone().one_of.expect("one_of missing"); @@ -857,10 +668,7 @@ mod tests { if let SchemaRef::Inline(array_schema) = inner_schema { assert_eq!(array_schema.schema_type, Some(SchemaType::Array)); if expected_prefix_items_len > 0 { - assert_eq!( - array_schema.prefix_items.as_ref().unwrap().len(), - expected_prefix_items_len - ); + assert_eq!(array_schema.prefix_items.as_ref().unwrap().len(), expected_prefix_items_len); } } else { panic!("Expected inline array schema"); @@ -872,13 +680,7 @@ mod tests { let inner_props = inner_obj.properties.as_ref().unwrap(); assert!(inner_props.contains_key("id")); assert!(inner_props.contains_key("note")); - assert!( - inner_obj - .required - .as_ref() - .unwrap() - .contains(&"id".to_string()) - ); + assert!(inner_obj.required.as_ref().unwrap().contains(&"id".to_string())); } else { panic!("Expected inline object schema"); } @@ -907,12 +709,7 @@ mod tests { SchemaType::String, "Ready" )] - fn test_parse_enum_to_schema_mixed_unit_variant( - #[case] enum_src: &str, - #[case] expected_one_of_len: usize, - #[case] expected_unit_type: SchemaType, - #[case] expected_unit_value: &str, - ) { + fn test_parse_enum_to_schema_mixed_unit_variant(#[case] enum_src: &str, #[case] expected_one_of_len: usize, #[case] expected_unit_type: SchemaType, #[case] expected_unit_value: &str) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); @@ -946,10 +743,7 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj - .properties - .as_ref() - .expect("variant props missing"); + let props = variant_obj.properties.as_ref().expect("variant props missing"); assert!(props.contains_key("data-item")); } @@ -971,10 +765,7 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj - .properties - .as_ref() - .expect("variant props missing"); + let props = variant_obj.properties.as_ref().expect("variant props missing"); let inner = match props.get("detail").expect("variant key missing") { SchemaRef::Inline(s) => s, _ => panic!("Expected inline inner schema"), @@ -1003,10 +794,7 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj - .properties - .as_ref() - .expect("variant props missing"); + let props = variant_obj.properties.as_ref().expect("variant props missing"); assert!(props.contains_key("Explicit")); assert!(!props.contains_key("data_item")); } @@ -1030,15 +818,8 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj - .properties - .as_ref() - .expect("variant props missing"); - let inner = match props - .get("detail") - .or_else(|| props.get("Detail")) - .expect("variant key missing") - { + let props = variant_obj.properties.as_ref().expect("variant props missing"); + let inner = match props.get("detail").or_else(|| props.get("Detail")).expect("variant key missing") { SchemaRef::Inline(s) => s, _ => panic!("Expected inline inner schema"), }; @@ -1062,20 +843,8 @@ mod tests { let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("id")); assert!(props.contains_key("name")); - assert!( - schema - .required - .as_ref() - .unwrap() - .contains(&"id".to_string()) - ); - assert!( - !schema - .required - .as_ref() - .unwrap() - .contains(&"name".to_string()) - ); + assert!(schema.required.as_ref().unwrap().contains(&"id".to_string())); + assert!(!schema.required.as_ref().unwrap().contains(&"name".to_string())); } #[test] @@ -1114,13 +883,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_empty_path_and_reference() { // Empty path segments returns object - let ty = Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); + let ty = Type::Path(syn::TypePath { qself: None, path: syn::Path { leading_colon: None, segments: syn::punctuated::Punctuated::new() } }); let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); assert!(matches!(schema_ref, SchemaRef::Inline(_))); @@ -1155,10 +918,7 @@ mod tests { known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string()); let mut struct_definitions = HashMap::new(); - struct_definitions.insert( - "Wrapper".to_string(), - "struct Wrapper { value: T }".to_string(), - ); + struct_definitions.insert("Wrapper".to_string(), "struct Wrapper { value: T }".to_string()); let ty: syn::Type = syn::parse_str("Wrapper").unwrap(); let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions); @@ -1178,10 +938,7 @@ mod tests { #[rstest] #[case("$invalid", "String")] - fn test_substitute_type_parse_failure_uses_original( - #[case] invalid: &str, - #[case] concrete_src: &str, - ) { + fn test_substitute_type_parse_failure_uses_original(#[case] invalid: &str, #[case] concrete_src: &str) { use proc_macro2::TokenStream; use std::str::FromStr; @@ -1200,21 +957,11 @@ mod tests { } #[rstest] - #[case( - "HashMap", - true, - None, - Some("#/components/schemas/Value") - )] + #[case("HashMap", true, None, Some("#/components/schemas/Value"))] #[case("Result", false, Some(SchemaType::Object), None)] #[case("crate::Value", false, None, None)] #[case("(i32, bool)", false, Some(SchemaType::Object), None)] - fn test_parse_type_to_schema_ref_additional_cases( - #[case] ty_src: &str, - #[case] expect_additional_props: bool, - #[case] expected_type: Option, - #[case] expected_ref: Option<&str>, - ) { + fn test_parse_type_to_schema_ref_additional_cases(#[case] ty_src: &str, #[case] expect_additional_props: bool, #[case] expected_type: Option, #[case] expected_ref: Option<&str>) { let mut known_schemas = HashMap::new(); known_schemas.insert("Value".to_string(), "Value".to_string()); @@ -1225,10 +972,7 @@ mod tests { let SchemaRef::Inline(schema) = schema_ref else { panic!("Expected inline schema for {}", ty_src); }; - let additional = schema - .additional_properties - .as_ref() - .expect("additional_properties missing"); + let additional = schema.additional_properties.as_ref().expect("additional_properties missing"); assert_eq!(additional.get("$ref").unwrap(), expected); } None => match schema_ref { @@ -1308,11 +1052,7 @@ mod tests { #[case("firstName", None, "firstName")] #[case("LastName", None, "LastName")] #[case("user-id", None, "user-id")] - fn test_rename_field( - #[case] field_name: &str, - #[case] rename_all: Option<&str>, - #[case] expected: &str, - ) { + fn test_rename_field(#[case] field_name: &str, #[case] rename_all: Option<&str>, #[case] expected: &str) { assert_eq!(rename_field(field_name, rename_all), expected); } } diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 75f8239..538b51b 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -9,25 +9,9 @@ pub struct RouteInfo { pub fn check_route_by_meta(meta: &syn::Meta) -> bool { match meta { - syn::Meta::List(meta_list) => { - (meta_list.path.segments.len() == 2 - && meta_list.path.segments[0].ident == "vespera" - && meta_list.path.segments[1].ident == "route") - || (meta_list.path.segments.len() == 1 - && meta_list.path.segments[0].ident == "route") - } - syn::Meta::Path(path) => { - (path.segments.len() == 2 - && path.segments[0].ident == "vespera" - && path.segments[1].ident == "route") - || (path.segments.len() == 1 && path.segments[0].ident == "route") - } - syn::Meta::NameValue(meta_nv) => { - (meta_nv.path.segments.len() == 2 - && meta_nv.path.segments[0].ident == "vespera" - && meta_nv.path.segments[1].ident == "route") - || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route") - } + syn::Meta::List(meta_list) => (meta_list.path.segments.len() == 2 && meta_list.path.segments[0].ident == "vespera" && meta_list.path.segments[1].ident == "route") || (meta_list.path.segments.len() == 1 && meta_list.path.segments[0].ident == "route"), + syn::Meta::Path(path) => (path.segments.len() == 2 && path.segments[0].ident == "vespera" && path.segments[1].ident == "route") || (path.segments.len() == 1 && path.segments[0].ident == "route"), + syn::Meta::NameValue(meta_nv) => (meta_nv.path.segments.len() == 2 && meta_nv.path.segments[0].ident == "vespera" && meta_nv.path.segments[1].ident == "route") || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route"), } } @@ -39,71 +23,37 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { syn::Meta::List(meta_list) => { // Try to parse as RouteArgs if let Ok(route_args) = meta_list.parse_args::() { - let method = route_args - .method - .as_ref() - .map(syn::Ident::to_string) - .unwrap_or_else(|| "get".to_string()); + let method = route_args.method.as_ref().map(syn::Ident::to_string).unwrap_or_else(|| "get".to_string()); let path = route_args.path.as_ref().map(syn::LitStr::value); // Parse error_status array if present let error_status = route_args.error_status.as_ref().and_then(|array| { let mut status_codes = Vec::new(); for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(lit_int), - .. - }) = elem + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. }) = elem && let Ok(code) = lit_int.base10_parse::() { status_codes.push(code); } } - if status_codes.is_empty() { - None - } else { - Some(status_codes) - } + if status_codes.is_empty() { None } else { Some(status_codes) } }); - return Some(RouteInfo { - method, - path, - error_status, - }); + return Some(RouteInfo { method, path, error_status }); } } // Try to parse as Meta::NameValue (e.g., #[route = "patch"]) syn::Meta::NameValue(meta_nv) => { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = &meta_nv.value - { + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = &meta_nv.value { let method_str = lit_str.value().to_lowercase(); - if method_str == "get" - || method_str == "post" - || method_str == "put" - || method_str == "patch" - || method_str == "delete" - || method_str == "head" - || method_str == "options" - { - return Some(RouteInfo { - method: method_str, - path: None, - error_status: None, - }); + if method_str == "get" || method_str == "post" || method_str == "put" || method_str == "patch" || method_str == "delete" || method_str == "head" || method_str == "options" { + return Some(RouteInfo { method: method_str, path: None, error_status: None }); } } } // Try to parse as Meta::Path (e.g., #[route]) syn::Meta::Path(_) => { - return Some(RouteInfo { - method: "get".to_string(), - path: None, - error_status: None, - }); + return Some(RouteInfo { method: "get".to_string(), path: None, error_status: None }); } } } @@ -161,11 +111,7 @@ mod tests { fn test_check_route_by_meta(#[case] attr_str: &str, #[case] expected: bool) { let meta = parse_meta_from_attr(attr_str); let result = check_route_by_meta(&meta); - assert_eq!( - result, expected, - "Failed for attribute: {}, expected: {}", - attr_str, expected - ); + assert_eq!(result, expected, "Failed for attribute: {}, expected: {}", attr_str, expected); } fn parse_attrs_from_code(code: &str) -> Vec { @@ -231,44 +177,20 @@ mod tests { #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] // method None, path None #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] // method Some, path None #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] // method Some, path Some - fn test_extract_route_info( - #[case] code: &str, - #[case] expected: Option<(String, Option, Option>)>, - ) { + fn test_extract_route_info(#[case] code: &str, #[case] expected: Option<(String, Option, Option>)>) { let attrs = parse_attrs_from_code(code); let result = extract_route_info(&attrs); match expected { Some((exp_method, exp_path, exp_error_status)) => { - assert!( - result.is_some(), - "Expected Some but got None for code: {}", - code - ); + assert!(result.is_some(), "Expected Some but got None for code: {}", code); let route_info = result.unwrap(); - assert_eq!( - route_info.method, exp_method, - "Method mismatch for code: {}", - code - ); - assert_eq!( - route_info.path, exp_path, - "Path mismatch for code: {}", - code - ); - assert_eq!( - route_info.error_status, exp_error_status, - "Error status mismatch for code: {}", - code - ); + assert_eq!(route_info.method, exp_method, "Method mismatch for code: {}", code); + assert_eq!(route_info.path, exp_path, "Path mismatch for code: {}", code); + assert_eq!(route_info.error_status, exp_error_status, "Error status mismatch for code: {}", code); } None => { - assert!( - result.is_none(), - "Expected None but got Some({:?}) for code: {}", - result, - code - ); + assert!(result.is_none(), "Expected None but got Some({:?}) for code: {}", result, code); } } } diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index 950dd7b..6479722 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -17,12 +17,5 @@ pub struct TestStruct { /// Create the application router for testing pub fn create_app() -> axum::Router { - vespera!( - openapi = ["examples/axum-example/openapi.json", "openapi.json"], - docs_url = "/docs", - redoc_url = "/redoc" - ) - .with_state(Arc::new(AppState { - config: "test".to_string(), - })) + vespera!(openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", redoc_url = "/redoc").with_state(Arc::new(AppState { config: "test".to_string() })) } diff --git a/examples/axum-example/src/routes/error.rs b/examples/axum-example/src/routes/error.rs index 4b8982a..656f732 100644 --- a/examples/axum-example/src/routes/error.rs +++ b/examples/axum-example/src/routes/error.rs @@ -24,42 +24,22 @@ impl IntoResponse for ErrorResponse2 { #[vespera::route()] pub async fn error_endpoint() -> Result<&'static str, Json> { - Err(Json(ErrorResponse { - error: "Internal server error".to_string(), - code: 500, - })) + Err(Json(ErrorResponse { error: "Internal server error".to_string(), code: 500 })) } #[vespera::route(path = "/error-with-status")] -pub async fn error_endpoint_with_status_code() --> Result<&'static str, (StatusCode, Json)> { - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Internal server error".to_string(), - code: 500, - }), - )) +pub async fn error_endpoint_with_status_code() -> Result<&'static str, (StatusCode, Json)> { + Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Internal server error".to_string(), code: 500 }))) } #[vespera::route(path = "/error2")] pub async fn error_endpoint2() -> Result<&'static str, ErrorResponse2> { - Err(ErrorResponse2 { - error: "Internal server error".to_string(), - code: 500, - }) + Err(ErrorResponse2 { error: "Internal server error".to_string(), code: 500 }) } #[vespera::route(path = "/error-with-status2", error_status = [500, 400, 404])] -pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusCode, ErrorResponse2)> -{ - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorResponse2 { - error: "Internal server error".to_string(), - code: 500, - }, - )) +pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusCode, ErrorResponse2)> { + Err((StatusCode::INTERNAL_SERVER_ERROR, ErrorResponse2 { error: "Internal server error".to_string(), code: 500 })) } #[vespera::route(path = "/header-map")] @@ -70,8 +50,7 @@ pub async fn header_map_endpoint() -> Result<(HeaderMap, &'static str), ErrorRes } #[vespera::route(path = "/header-map2")] -pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static str), ErrorResponse2> -{ +pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static str), ErrorResponse2> { let headers = HeaderMap::new(); println!("headers: {:?}", headers); Ok((StatusCode::INTERNAL_SERVER_ERROR, headers, "ok")) diff --git a/examples/axum-example/src/routes/foo/mod.rs b/examples/axum-example/src/routes/foo/mod.rs index 9981578..f82bc3b 100644 --- a/examples/axum-example/src/routes/foo/mod.rs +++ b/examples/axum-example/src/routes/foo/mod.rs @@ -30,21 +30,8 @@ pub struct SignupResponse { } #[vespera::route(post, path = "/foo")] -pub async fn signup( - State(app_state): State>, - Json(request): Json, -) -> Result, String> { +pub async fn signup(State(app_state): State>, Json(request): Json) -> Result, String> { println!("app_state: {:?}", app_state.config); - let response = SignupResponse { - id: 1, - email: request.email, - name: "John Doe".to_string(), - phone_number: "1234567890".to_string(), - nickname: Some("John".to_string()), - birthday: Some("1990-01-01".to_string()), - gender: Some("male".to_string()), - job: Some("engineer".to_string()), - created_at: "2021-01-01".to_string(), - }; + let response = SignupResponse { id: 1, email: request.email, name: "John Doe".to_string(), phone_number: "1234567890".to_string(), nickname: Some("John".to_string()), birthday: Some("1990-01-01".to_string()), gender: Some("male".to_string()), job: Some("engineer".to_string()), created_at: "2021-01-01".to_string() }; Ok(Json(response)) } diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs index 45857d0..3539da4 100644 --- a/examples/axum-example/src/routes/generic.rs +++ b/examples/axum-example/src/routes/generic.rs @@ -17,43 +17,21 @@ pub struct GenericStruct2 { } #[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(), - }) +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(), - }) + 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(), - }) + 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(), - }) + 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 3b6c1b9..de8b4a4 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -86,9 +86,7 @@ pub struct StructBodyWithOptional { } #[vespera::route(post, path = "/struct-body-with-optional")] -pub async fn mod_file_with_struct_body_with_optional( - Json(body): Json, -) -> String { +pub async fn mod_file_with_struct_body_with_optional(Json(body): Json) -> String { format!("name: {:?}, age: {:?}", body.name, body.age) } @@ -109,20 +107,7 @@ pub struct ComplexStructBody { #[vespera::route(post, path = "/complex-struct-body")] pub async fn mod_file_with_complex_struct_body(Json(body): Json) -> String { - format!( - "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", - body.name, - body.age, - body.nested_struct, - body.array, - body.map, - body.nested_array, - body.nested_map, - body.nested_struct_array, - body.nested_struct_map, - body.nested_struct_array_map, - body.nested_struct_map_array - ) + format!("name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", body.name, body.age, body.nested_struct, body.array, body.map, body.nested_array, body.nested_map, body.nested_struct_array, body.nested_struct_map, body.nested_struct_array_map, body.nested_struct_map_array) } #[derive(Deserialize, Schema)] @@ -142,23 +127,8 @@ pub struct ComplexStructBodyWithRename { } #[vespera::route(post, path = "/complex-struct-body-with-rename")] -pub async fn mod_file_with_complex_struct_body_with_rename( - Json(body): Json, -) -> String { - format!( - "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", - body.name, - body.age, - body.nested_struct, - body.array, - body.map, - body.nested_array, - body.nested_map, - body.nested_struct_array, - body.nested_struct_map, - body.nested_struct_array_map, - body.nested_struct_map_array - ) +pub async fn mod_file_with_complex_struct_body_with_rename(Json(body): Json) -> String { + format!("name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", body.name, body.age, body.nested_struct, body.array, body.map, body.nested_array, body.nested_map, body.nested_struct_array, body.nested_struct_map, body.nested_struct_array_map, body.nested_struct_map_array) } #[vespera::route(get, path = "/test_struct")] diff --git a/examples/axum-example/src/routes/path/mod.rs b/examples/axum-example/src/routes/path/mod.rs index d8e86ad..35539ca 100644 --- a/examples/axum-example/src/routes/path/mod.rs +++ b/examples/axum-example/src/routes/path/mod.rs @@ -1,22 +1,14 @@ pub mod prefix; #[vespera::route(get, path = "/multi-path/{var1}")] -pub async fn mod_file_with_test_struct( - vespera::axum::extract::Path(var1): vespera::axum::extract::Path, -) -> &'static str { +pub async fn mod_file_with_test_struct(vespera::axum::extract::Path(var1): vespera::axum::extract::Path) -> &'static str { println!("var1: {}", var1); "multi path" } // multi path #[vespera::route(get, path = "/multi-path/{arg}/{var1}/{var2}")] -pub async fn mod_file_with_multi_path( - vespera::axum::extract::Path((arg2, var1, var2)): vespera::axum::extract::Path<( - String, - String, - String, - )>, -) -> &'static str { +pub async fn mod_file_with_multi_path(vespera::axum::extract::Path((arg2, var1, var2)): vespera::axum::extract::Path<(String, String, String)>) -> &'static str { println!("arg: {}", arg2); println!("var1: {}", var1); println!("var2: {}", var2); @@ -25,9 +17,7 @@ pub async fn mod_file_with_multi_path( // multi path #[vespera::route(get, path = "/multi-path2/{arg}/{var1}/{var2}")] -pub async fn mod_file_with_multi_path_2( - vespera::axum::extract::Path(path): vespera::axum::extract::Path<(String, String, String)>, -) -> &'static str { +pub async fn mod_file_with_multi_path_2(vespera::axum::extract::Path(path): vespera::axum::extract::Path<(String, String, String)>) -> &'static str { println!("arg: {:?}", path); "multi path" } diff --git a/examples/axum-example/src/routes/typed_header.rs b/examples/axum-example/src/routes/typed_header.rs index 78bfa20..29ba06b 100644 --- a/examples/axum-example/src/routes/typed_header.rs +++ b/examples/axum-example/src/routes/typed_header.rs @@ -4,19 +4,14 @@ use vespera::axum_extra::{ }; #[vespera::route(post)] -pub async fn typed_header( - TypedHeader(user_agent): TypedHeader, - content_type: Option>, -) -> &'static str { +pub async fn typed_header(TypedHeader(user_agent): TypedHeader, content_type: Option>) -> &'static str { println!("user_agent: {:?}", user_agent); println!("content_type: {:?}", content_type); "ok" } #[vespera::route()] -pub async fn typed_header_jwt( - TypedHeader(authorization): TypedHeader>, -) -> &'static str { +pub async fn typed_header_jwt(TypedHeader(authorization): TypedHeader>) -> &'static str { println!("authorization: {:?}", authorization); "ok" } diff --git a/examples/axum-example/src/routes/users.rs b/examples/axum-example/src/routes/users.rs index d1bd81f..ed378d8 100644 --- a/examples/axum-example/src/routes/users.rs +++ b/examples/axum-example/src/routes/users.rs @@ -20,36 +20,17 @@ pub struct CreateUserRequest { /// Get all users #[vespera::route(get)] pub async fn get_users() -> Json> { - Json(vec![ - User { - id: 1, - name: "Alice".to_string(), - email: "alice@example.com".to_string(), - }, - User { - id: 2, - name: "Bob".to_string(), - email: "bob@example.com".to_string(), - }, - ]) + Json(vec![User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string() }, User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string() }]) } /// Get user by ID #[vespera::route(get, path = "/{id}")] pub async fn get_user(Path(id): Path) -> Json { - Json(User { - id, - name: format!("User {}", id), - email: format!("user{}@example.com", id), - }) + Json(User { id, name: format!("User {}", id), email: format!("user{}@example.com", id) }) } /// Create a new user #[vespera::route(post)] pub async fn create_user(Json(user): Json) -> Json { - Json(User { - id: 100, - name: user.name, - email: user.email, - }) + Json(User { id: 100, name: user.name, email: user.email }) } diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 8d5b8a1..9853800 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -191,10 +191,7 @@ async fn test_mod_file_with_complex_struct_body() { } }); - let response = server - .post("/complex-struct-body") - .json(&complex_body) - .await; + let response = server.post("/complex-struct-body").json(&complex_body).await; response.assert_status_ok(); let response_text = response.text(); @@ -280,10 +277,7 @@ async fn test_mod_file_with_complex_struct_body_with_rename() { } }); - let response = server - .post("/complex-struct-body-with-rename") - .json(&complex_body) - .await; + let response = server.post("/complex-struct-body-with-rename").json(&complex_body).await; response.assert_status_ok(); let response_text = response.text(); From 2692dbc5f1dec84df9c3ebc94f815352deaadad2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 20:44:41 +0900 Subject: [PATCH 23/30] Fix fmt --- .rustfmt.toml | 8 - crates/vespera_core/src/route.rs | 124 +++++- crates/vespera_core/src/schema.rs | 52 ++- crates/vespera_macro/src/args.rs | 86 +++- crates/vespera_macro/src/collector.rs | 61 ++- crates/vespera_macro/src/file_utils.rs | 52 ++- crates/vespera_macro/src/lib.rs | 224 +++++++++-- crates/vespera_macro/src/metadata.rs | 5 +- crates/vespera_macro/src/method.rs | 54 ++- crates/vespera_macro/src/openapi_generator.rs | 198 ++++++++-- crates/vespera_macro/src/parser/operation.rs | 156 +++++++- crates/vespera_macro/src/parser/parameters.rs | 233 +++++++++-- .../vespera_macro/src/parser/request_body.rs | 66 +++- crates/vespera_macro/src/parser/response.rs | 141 +++++-- crates/vespera_macro/src/parser/schema.rs | 368 +++++++++++++++--- crates/vespera_macro/src/route/utils.rs | 114 +++++- examples/axum-example/src/lib.rs | 9 +- examples/axum-example/src/routes/error.rs | 35 +- examples/axum-example/src/routes/foo/mod.rs | 17 +- examples/axum-example/src/routes/generic.rs | 32 +- examples/axum-example/src/routes/mod.rs | 38 +- examples/axum-example/src/routes/path/mod.rs | 16 +- .../axum-example/src/routes/typed_header.rs | 9 +- examples/axum-example/src/routes/users.rs | 25 +- .../axum-example/tests/integration_test.rs | 10 +- 25 files changed, 1817 insertions(+), 316 deletions(-) delete mode 100644 .rustfmt.toml diff --git a/.rustfmt.toml b/.rustfmt.toml deleted file mode 100644 index 0a6a768..0000000 --- a/.rustfmt.toml +++ /dev/null @@ -1,8 +0,0 @@ -max_width = 100000 -tab_spaces = 4 -newline_style = "Unix" -fn_call_width = 100000 -fn_params_layout = "Compressed" -chain_width = 100000 -merge_derives = true -use_small_heuristics = "Default" diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index a3987c4..123dc88 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -319,21 +319,48 @@ mod tests { #[test] fn test_path_item_set_operation() { - let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; - - let operation = Operation { operation_id: Some("test_operation".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; + let mut path_item = PathItem { + get: None, + post: None, + put: None, + patch: None, + delete: None, + head: None, + options: None, + trace: None, + parameters: None, + summary: None, + description: None, + }; + + let operation = Operation { + operation_id: Some("test_operation".to_string()), + tags: None, + summary: None, + description: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + }; // Test setting GET operation path_item.set_operation(HttpMethod::Get, operation.clone()); assert!(path_item.get.is_some()); - assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("test_operation".to_string())); + assert_eq!( + path_item.get.as_ref().unwrap().operation_id, + Some("test_operation".to_string()) + ); // Test setting POST operation let mut operation_post = operation.clone(); operation_post.operation_id = Some("post_operation".to_string()); path_item.set_operation(HttpMethod::Post, operation_post); assert!(path_item.post.is_some()); - assert_eq!(path_item.post.as_ref().unwrap().operation_id, Some("post_operation".to_string())); + assert_eq!( + path_item.post.as_ref().unwrap().operation_id, + Some("post_operation".to_string()) + ); // Test setting PUT operation let mut operation_put = operation.clone(); @@ -374,9 +401,30 @@ mod tests { #[test] fn test_path_item_get_operation() { - let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; - - let operation = Operation { operation_id: Some("test_operation".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; + let mut path_item = PathItem { + get: None, + post: None, + put: None, + patch: None, + delete: None, + head: None, + options: None, + trace: None, + parameters: None, + summary: None, + description: None, + }; + + let operation = Operation { + operation_id: Some("test_operation".to_string()), + tags: None, + summary: None, + description: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + }; // Initially, all operations should be None assert!(path_item.get_operation(&HttpMethod::Get).is_none()); @@ -386,7 +434,10 @@ mod tests { path_item.set_operation(HttpMethod::Get, operation.clone()); let retrieved = path_item.get_operation(&HttpMethod::Get); assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().operation_id, Some("test_operation".to_string())); + assert_eq!( + retrieved.unwrap().operation_id, + Some("test_operation".to_string()) + ); // Set POST operation let mut operation_post = operation.clone(); @@ -394,7 +445,10 @@ mod tests { path_item.set_operation(HttpMethod::Post, operation_post); let retrieved = path_item.get_operation(&HttpMethod::Post); assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().operation_id, Some("post_operation".to_string())); + assert_eq!( + retrieved.unwrap().operation_id, + Some("post_operation".to_string()) + ); // Test all methods path_item.set_operation(HttpMethod::Put, operation.clone()); @@ -418,19 +472,55 @@ mod tests { #[test] fn test_path_item_set_operation_overwrites() { - let mut path_item = PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }; - - let operation1 = Operation { operation_id: Some("first".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; - - let operation2 = Operation { operation_id: Some("second".to_string()), tags: None, summary: None, description: None, parameters: None, request_body: None, responses: BTreeMap::new(), security: None }; + let mut path_item = PathItem { + get: None, + post: None, + put: None, + patch: None, + delete: None, + head: None, + options: None, + trace: None, + parameters: None, + summary: None, + description: None, + }; + + let operation1 = Operation { + operation_id: Some("first".to_string()), + tags: None, + summary: None, + description: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + }; + + let operation2 = Operation { + operation_id: Some("second".to_string()), + tags: None, + summary: None, + description: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + }; // Set first operation path_item.set_operation(HttpMethod::Get, operation1); - assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("first".to_string())); + assert_eq!( + path_item.get.as_ref().unwrap().operation_id, + Some("first".to_string()) + ); // Overwrite with second operation path_item.set_operation(HttpMethod::Get, operation2); - assert_eq!(path_item.get.as_ref().unwrap().operation_id, Some("second".to_string())); + assert_eq!( + path_item.get.as_ref().unwrap().operation_id, + Some("second".to_string()) + ); } #[test] diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index a2974b6..d1b9330 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -214,7 +214,46 @@ pub struct Schema { 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, description: None, default: None, example: None, examples: None, minimum: None, maximum: None, exclusive_minimum: None, exclusive_maximum: None, multiple_of: None, min_length: None, max_length: None, pattern: None, items: None, prefix_items: None, min_items: None, max_items: None, unique_items: None, properties: None, required: None, additional_properties: None, min_properties: None, max_properties: None, r#enum: None, all_of: None, any_of: None, one_of: None, not: None, nullable: None, read_only: None, write_only: None, external_docs: None, defs: None, dynamic_anchor: None, dynamic_ref: None } + Self { + ref_path: None, + schema_type: Some(schema_type), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + r#enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + } } /// Create a string schema @@ -239,12 +278,19 @@ impl Schema { /// Create an array schema pub fn array(items: SchemaRef) -> Self { - Self { items: Some(Box::new(items)), ..Self::new(SchemaType::Array) } + Self { + items: Some(Box::new(items)), + ..Self::new(SchemaType::Array) + } } /// Create an object schema pub fn object() -> Self { - Self { properties: Some(BTreeMap::new()), required: Some(Vec::new()), ..Self::new(SchemaType::Object) } + Self { + properties: Some(BTreeMap::new()), + required: Some(Vec::new()), + ..Self::new(SchemaType::Object) + } } } diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index fd43128..418a77b 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -48,7 +48,11 @@ impl syn::parse::Parse for RouteArgs { } } - Ok(RouteArgs { method, path, error_status }) + Ok(RouteArgs { + method, + path, + error_status, + }) } } @@ -105,49 +109,105 @@ mod tests { #[case(",", false, None, None, None)] #[case("get, 123", false, None, None, None)] #[case("get, =", false, None, None, None)] - fn test_route_args_parse(#[case] input: &str, #[case] should_parse: bool, #[case] expected_method: Option<&str>, #[case] expected_path: Option<&str>, #[case] expected_error_status: Option>) { + fn test_route_args_parse( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_method: Option<&str>, + #[case] expected_path: Option<&str>, + #[case] expected_error_status: Option>, + ) { let result = syn::parse_str::(input); match (should_parse, result) { (true, Ok(route_args)) => { // Check method if let Some(exp_method) = expected_method { - assert!(route_args.method.is_some(), "Expected method {} but got None for input: {}", exp_method, input); - assert_eq!(route_args.method.as_ref().unwrap().to_string(), exp_method, "Method mismatch for input: {}", input); + assert!( + route_args.method.is_some(), + "Expected method {} but got None for input: {}", + exp_method, + input + ); + assert_eq!( + route_args.method.as_ref().unwrap().to_string(), + exp_method, + "Method mismatch for input: {}", + input + ); } else { - assert!(route_args.method.is_none(), "Expected no method but got {:?} for input: {}", route_args.method, input); + assert!( + route_args.method.is_none(), + "Expected no method but got {:?} for input: {}", + route_args.method, + input + ); } // Check path if let Some(exp_path) = expected_path { - assert!(route_args.path.is_some(), "Expected path {} but got None for input: {}", exp_path, input); - assert_eq!(route_args.path.as_ref().unwrap().value(), exp_path, "Path mismatch for input: {}", input); + assert!( + route_args.path.is_some(), + "Expected path {} but got None for input: {}", + exp_path, + input + ); + assert_eq!( + route_args.path.as_ref().unwrap().value(), + exp_path, + "Path mismatch for input: {}", + input + ); } else { - assert!(route_args.path.is_none(), "Expected no path but got {:?} for input: {}", route_args.path, input); + assert!( + route_args.path.is_none(), + "Expected no path but got {:?} for input: {}", + route_args.path, + input + ); } // Check error_status if let Some(exp_status) = expected_error_status { - assert!(route_args.error_status.is_some(), "Expected error_status {:?} but got None for input: {}", exp_status, input); + assert!( + route_args.error_status.is_some(), + "Expected error_status {:?} but got None for input: {}", + exp_status, + input + ); let array = route_args.error_status.as_ref().unwrap(); let mut status_codes = Vec::new(); for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. }) = elem + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = elem && let Ok(code) = lit_int.base10_parse::() { status_codes.push(code); } } - assert_eq!(status_codes, exp_status, "Error status mismatch for input: {}", input); + assert_eq!( + status_codes, exp_status, + "Error status mismatch for input: {}", + input + ); } else { - assert!(route_args.error_status.is_none(), "Expected no error_status but got {:?} for input: {}", route_args.error_status, input); + assert!( + route_args.error_status.is_none(), + "Expected no error_status but got {:?} for input: {}", + route_args.error_status, + input + ); } } (false, Err(_)) => { // Expected error, test passes } (true, Err(e)) => { - panic!("Expected successful parse but got error: {} for input: {}", e, input); + panic!( + "Expected successful parse but got error: {} for input: {}", + e, input + ); } (false, Ok(_)) => { panic!("Expected parse error but got success for input: {}", input); diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index fa93428..7539ce6 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -11,21 +11,39 @@ use syn::Item; pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result { let mut metadata = CollectedMetadata::new(); - let files = collect_files(folder_path).with_context(|| format!("Failed to collect files from wtf: {}", folder_path.display()))?; + let files = collect_files(folder_path).with_context(|| { + format!( + "Failed to collect files from wtf: {}", + folder_path.display() + ) + })?; for file in files { if !file.extension().map(|e| e == "rs").unwrap_or(false) { continue; } - let content = std::fs::read_to_string(&file).with_context(|| format!("Failed to read file: {}", file.display()))?; + let content = std::fs::read_to_string(&file) + .with_context(|| format!("Failed to read file: {}", file.display()))?; - let file_ast = syn::parse_file(&content).with_context(|| format!("Failed to parse file: {}", file.display()))?; + let file_ast = syn::parse_file(&content) + .with_context(|| format!("Failed to parse file: {}", file.display()))?; // Get module path - let segments = file.strip_prefix(folder_path).map(|file_stem| file_to_segments(file_stem, folder_path)).context(format!("Failed to strip prefix from file: {} (base: {})", file.display(), folder_path.display()))?; - - let module_path = if folder_name.is_empty() { segments.join("::") } else { format!("{}::{}", folder_name, segments.join("::")) }; + let segments = file + .strip_prefix(folder_path) + .map(|file_stem| file_to_segments(file_stem, folder_path)) + .context(format!( + "Failed to strip prefix from file: {} (base: {})", + file.display(), + folder_path.display() + ))?; + + let module_path = if folder_name.is_empty() { + segments.join("::") + } else { + format!("{}::{}", folder_name, segments.join("::")) + }; let file_path = file.display().to_string(); @@ -43,7 +61,15 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result String { "get_users", "routes::api::v1::users", )] - fn test_collect_metadata_routes(#[case] folder_name: &str, #[case] files: Vec<(&str, &str)>, #[case] expected_method: &str, #[case] expected_path: &str, #[case] expected_function_name: &str, #[case] expected_module_path: &str) { + fn test_collect_metadata_routes( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_name: &str, + #[case] expected_module_path: &str, + ) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); for (filename, content) in &files { @@ -192,7 +225,11 @@ pub fn get_users() -> String { assert_eq!(route.function_name, expected_function_name); assert_eq!(route.module_path, expected_module_path); if let Some((first_filename, _)) = files.first() { - assert!(route.file_path.contains(first_filename.split('/').next().unwrap())); + assert!( + route + .file_path + .contains(first_filename.split('/').next().unwrap()) + ); } drop(temp_dir); @@ -306,7 +343,11 @@ pub fn get_posts() -> String { assert_eq!(metadata.structs.len(), 0); // Check all routes are present - let function_names: Vec<&str> = metadata.routes.iter().map(|r| r.function_name.as_str()).collect(); + let function_names: Vec<&str> = metadata + .routes + .iter() + .map(|r| r.function_name.as_str()) + .collect(); assert!(function_names.contains(&"get_users")); assert!(function_names.contains(&"create_users")); assert!(function_names.contains(&"get_posts")); diff --git a/crates/vespera_macro/src/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index 9375ddc..690a9e9 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -3,7 +3,9 @@ use std::path::{Path, PathBuf}; pub fn collect_files(folder_path: &Path) -> Result> { let mut files = Vec::new(); - for entry in std::fs::read_dir(folder_path).with_context(|| format!("Failed to read directory: {}", folder_path.display()))? { + for entry in std::fs::read_dir(folder_path) + .with_context(|| format!("Failed to read directory: {}", folder_path.display()))? + { let entry = entry.with_context(|| "Failed to read directory entry")?; let path = entry.path(); if path.is_file() { @@ -16,9 +18,17 @@ pub fn collect_files(folder_path: &Path) -> Result> { } pub fn file_to_segments(file: &Path, base_path: &Path) -> Vec { - let file_stem = if let Ok(file_stem) = file.strip_prefix(base_path) { file_stem.display().to_string() } else { file.display().to_string() }; + let file_stem = if let Ok(file_stem) = file.strip_prefix(base_path) { + file_stem.display().to_string() + } else { + file.display().to_string() + }; let file_stem = file_stem.replace(".rs", "").replace("\\", "/"); - let mut segments: Vec = file_stem.split("/").filter(|s| !s.is_empty()).map(|s| s.to_string()).collect(); + let mut segments: Vec = file_stem + .split("/") + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); if let Some(last) = segments.last() && last == "mod" { @@ -68,7 +78,11 @@ mod tests { // Root level files #[case("users.rs", ".", vec!["users"])] #[case("mod.rs", ".", vec![])] - fn test_file_to_segments(#[case] file_path: &str, #[case] base_path: &str, #[case] expected: Vec<&str>) { + fn test_file_to_segments( + #[case] file_path: &str, + #[case] base_path: &str, + #[case] expected: Vec<&str>, + ) { // Normalize paths by replacing backslashes with forward slashes // This ensures tests work cross-platform (Windows uses \, Unix uses /) let normalized_file_path = file_path.replace("\\", "/"); @@ -77,10 +91,17 @@ mod tests { let base = PathBuf::from(normalized_base_path); let result = file_to_segments(&file, &base); let expected_vec: Vec = expected.iter().map(|s| s.to_string()).collect(); - assert_eq!(result, expected_vec, "Failed for file: {}, base: {}", file_path, base_path); + assert_eq!( + result, expected_vec, + "Failed for file: {}, base: {}", + file_path, base_path + ); } - fn create_test_structure(temp_dir: &TempDir, structure: &[(&str, bool)]) -> Result<(), std::io::Error> { + fn create_test_structure( + temp_dir: &TempDir, + structure: &[(&str, bool)], + ) -> Result<(), std::io::Error> { // (path, is_file) for (path, is_file) in structure { let full_path = temp_dir.path().join(path); @@ -97,7 +118,15 @@ mod tests { } fn normalize_paths(paths: &[PathBuf], base: &Path) -> Vec { - let mut normalized: Vec = paths.iter().map(|p| p.strip_prefix(base).unwrap_or(p).to_string_lossy().replace("\\", "/")).collect(); + let mut normalized: Vec = paths + .iter() + .map(|p| { + p.strip_prefix(base) + .unwrap_or(p) + .to_string_lossy() + .replace("\\", "/") + }) + .collect(); normalized.sort(); normalized } @@ -175,10 +204,15 @@ mod tests { let mut normalized_result = normalize_paths(&result, temp_dir.path()); normalized_result.sort(); - let mut expected_normalized: Vec = expected_files.iter().map(|s| s.to_string()).collect(); + let mut expected_normalized: Vec = + expected_files.iter().map(|s| s.to_string()).collect(); expected_normalized.sort(); - assert_eq!(normalized_result, expected_normalized, "Failed for structure: {:?}", structure); + assert_eq!( + normalized_result, expected_normalized, + "Failed for structure: {:?}", + structure + ); temp_dir.close().expect("Failed to close temp dir"); } diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 7b34743..e11f644 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -30,7 +30,8 @@ pub fn route(_attr: TokenStream, item: TokenStream) -> TokenStream { } // Schema Storage global variable -static SCHEMA_STORAGE: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static SCHEMA_STORAGE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); /// Derive macro for Schema #[proc_macro_derive(Schema)] @@ -40,7 +41,10 @@ pub fn derive_schema(input: TokenStream) -> TokenStream { let generics = &input.generics; let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); - schema_storage.push(StructMetadata { name: name.to_string(), definition: quote::quote!(#input).to_string() }); + schema_storage.push(StructMetadata { + name: name.to_string(), + definition: quote::quote!(#input).to_string(), + }); // Mark both struct and enum as having SchemaBuilder // For generic types, include the generic parameters in the impl @@ -103,7 +107,13 @@ impl Parse for AutoRouterInput { version = Some(input.parse()?); } _ => { - return Err(syn::Error::new(ident.span(), format!("unknown field: `{}`. Expected `dir` or `openapi`", ident_str))); + return Err(syn::Error::new( + ident.span(), + format!( + "unknown field: `{}`. Expected `dir` or `openapi`", + ident_str + ), + )); } } } else if lookahead.peek(syn::LitStr) { @@ -120,7 +130,38 @@ impl Parse for AutoRouterInput { } } - Ok(AutoRouterInput { dir: dir.or_else(|| std::env::var("VESPERA_DIR").map(|f| LitStr::new(&f, Span::call_site())).ok()), openapi: openapi.or_else(|| std::env::var("VESPERA_OPENAPI").map(|f| vec![LitStr::new(&f, Span::call_site())]).ok()), title: title.or_else(|| std::env::var("VESPERA_TITLE").map(|f| LitStr::new(&f, Span::call_site())).ok()), version: version.or_else(|| std::env::var("VESPERA_VERSION").map(|f| LitStr::new(&f, Span::call_site())).ok()), docs_url: docs_url.or_else(|| std::env::var("VESPERA_DOCS_URL").map(|f| LitStr::new(&f, Span::call_site())).ok()), redoc_url: redoc_url.or_else(|| std::env::var("VESPERA_REDOC_URL").map(|f| LitStr::new(&f, Span::call_site())).ok()) }) + Ok(AutoRouterInput { + dir: dir.or_else(|| { + std::env::var("VESPERA_DIR") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + openapi: openapi.or_else(|| { + std::env::var("VESPERA_OPENAPI") + .map(|f| vec![LitStr::new(&f, Span::call_site())]) + .ok() + }), + title: title.or_else(|| { + std::env::var("VESPERA_TITLE") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + version: version.or_else(|| { + std::env::var("VESPERA_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + docs_url: docs_url.or_else(|| { + std::env::var("VESPERA_DOCS_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + redoc_url: redoc_url.or_else(|| { + std::env::var("VESPERA_REDOC_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + }) } } @@ -130,7 +171,8 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result> { if input.peek(syn::token::Bracket) { let content; let _ = bracketed!(content in input); - let entries: Punctuated = content.parse_terminated(|input| input.parse::(), syn::Token![,])?; + let entries: Punctuated = + content.parse_terminated(|input| input.parse::(), syn::Token![,])?; Ok(entries.into_iter().collect()) } else { let single: LitStr = input.parse()?; @@ -142,9 +184,17 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result> { pub fn vespera(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as AutoRouterInput); - let folder_name = input.dir.map(|f| f.value()).unwrap_or_else(|| "routes".to_string()); + let folder_name = input + .dir + .map(|f| f.value()) + .unwrap_or_else(|| "routes".to_string()); - let openapi_file_names = input.openapi.unwrap_or_default().into_iter().map(|f| f.value()).collect::>(); + let openapi_file_names = input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect::>(); let title = input.title.map(|t| t.value()); let version = input.version.map(|v| v.value()); @@ -154,13 +204,23 @@ pub fn vespera(input: TokenStream) -> TokenStream { let folder_path = find_folder_path(&folder_name); if !folder_path.exists() { - return syn::Error::new(Span::call_site(), format!("Folder not found: {}", folder_name)).to_compile_error().into(); + return syn::Error::new( + Span::call_site(), + format!("Folder not found: {}", folder_name), + ) + .to_compile_error() + .into(); } let mut metadata = match collect_metadata(&folder_path, &folder_name) { Ok(metadata) => metadata, Err(e) => { - return syn::Error::new(Span::call_site(), format!("Failed to collect metadata: {}", e)).to_compile_error().into(); + return syn::Error::new( + Span::call_site(), + format!("Failed to collect metadata: {}", e), + ) + .to_compile_error() + .into(); } }; let schemas = SCHEMA_STORAGE.lock().unwrap().clone(); @@ -174,15 +234,30 @@ pub fn vespera(input: TokenStream) -> TokenStream { // Generate OpenAPI document using collected metadata // Serialize to JSON - let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata(title, version, &metadata)) { + let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( + title, version, &metadata, + )) { Ok(json) => json, Err(e) => { - return syn::Error::new(Span::call_site(), format!("Failed to serialize OpenAPI document: {}", e)).to_compile_error().into(); + return syn::Error::new( + Span::call_site(), + format!("Failed to serialize OpenAPI document: {}", e), + ) + .to_compile_error() + .into(); } }; for openapi_file_name in &openapi_file_names { if let Err(e) = std::fs::write(openapi_file_name, &json_str) { - return syn::Error::new(Span::call_site(), format!("Failed to write OpenAPI document to {}: {}", openapi_file_name, e)).to_compile_error().into(); + return syn::Error::new( + Span::call_site(), + format!( + "Failed to write OpenAPI document to {}: {}", + openapi_file_name, e + ), + ) + .to_compile_error() + .into(); } } if let Some(docs_url) = docs_url { @@ -207,7 +282,11 @@ fn find_folder_path(folder_name: &str) -> std::path::PathBuf { Path::new(folder_name).to_path_buf() } -fn generate_router_code(metadata: &CollectedMetadata, docs_info: Option<(String, String)>, redoc_info: Option<(String, String)>) -> proc_macro2::TokenStream { +fn generate_router_code( + metadata: &CollectedMetadata, + docs_info: Option<(String, String)>, + redoc_info: Option<(String, String)>, +) -> proc_macro2::TokenStream { let mut router_nests = Vec::new(); for route in &metadata.routes { @@ -217,9 +296,27 @@ fn generate_router_code(metadata: &CollectedMetadata, docs_info: Option<(String, let module_path = &route.module_path; let function_name = &route.function_name; - let mut p: syn::punctuated::Punctuated = syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { ident: syn::Ident::new("crate", Span::call_site()), arguments: syn::PathArguments::None }); - p.extend(module_path.split("::").filter_map(|s| if s.is_empty() { None } else { Some(syn::PathSegment { ident: syn::Ident::new(s, Span::call_site()), arguments: syn::PathArguments::None }) }).collect::>()); + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend( + module_path + .split("::") + .filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + }) + .collect::>(), + ); let func_name = syn::Ident::new(function_name, Span::call_site()); router_nests.push(quote!( .route(#path, #method_path(#p::#func_name)) @@ -337,13 +434,25 @@ mod tests { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Should generate empty router // quote! generates "vespera :: axum :: Router :: new ()" format - assert!(code.contains("Router") && code.contains("new"), "Code should contain Router::new(), got: {}", code); - assert!(!code.contains("route"), "Code should not contain route, got: {}", code); + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {}", + code + ); + assert!( + !code.contains("route"), + "Code should not contain route, got: {}", + code + ); drop(temp_dir); } @@ -469,30 +578,59 @@ pub fn get_users() -> String { "/api/v1/users", "routes::api::v1::users::get_users", )] - fn test_generate_router_code_single_route(#[case] folder_name: &str, #[case] files: Vec<(&str, &str)>, #[case] expected_method: &str, #[case] expected_path: &str, #[case] expected_function_path: &str) { + fn test_generate_router_code_single_route( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_path: &str, + ) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); for (filename, content) in files { create_temp_file(&temp_dir, filename, content); } - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new"), "Code should contain Router::new(), got: {}", code); + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {}", + code + ); // Check route method - assert!(code.contains(expected_method), "Code should contain method: {}, got: {}", expected_method, code); + assert!( + code.contains(expected_method), + "Code should contain method: {}, got: {}", + expected_method, + code + ); // Check route path - assert!(code.contains(expected_path), "Code should contain path: {}, got: {}", expected_path, code); + assert!( + code.contains(expected_path), + "Code should contain path: {}, got: {}", + expected_path, + code + ); // Check function path (quote! adds spaces, so we check for parts) let function_parts: Vec<&str> = expected_function_path.split("::").collect(); for part in &function_parts { if !part.is_empty() { - assert!(code.contains(part), "Code should contain function part: {}, got: {}", part, code); + assert!( + code.contains(part), + "Code should contain function part: {}, got: {}", + part, + code + ); } } @@ -538,7 +676,11 @@ pub fn update_user() -> String { "#, ); - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -557,7 +699,11 @@ pub fn update_user() -> String { // Count route calls (quote! generates ". route (" with spaces) // Count occurrences of ". route (" pattern let route_count = code.matches(". route (").count(); - assert_eq!(route_count, 3, "Should have 3 route calls, got: {}, code: {}", route_count, code); + assert_eq!( + route_count, 3, + "Should have 3 route calls, got: {}, code: {}", + route_count, code + ); drop(temp_dir); } @@ -584,7 +730,11 @@ pub fn create_users() -> String { "#, ); - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -600,7 +750,11 @@ pub fn create_users() -> String { // Should have 2 routes (quote! generates ". route (" with spaces) let route_count = code.matches(". route (").count(); - assert_eq!(route_count, 2, "Should have 2 routes, got: {}, code: {}", route_count, code); + assert_eq!( + route_count, 2, + "Should have 2 routes, got: {}, code: {}", + route_count, code + ); drop(temp_dir); } @@ -622,7 +776,11 @@ pub fn index() -> String { "#, ); - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") @@ -654,7 +812,11 @@ pub fn get_users() -> String { "#, ); - let result = generate_router_code(&collect_metadata(temp_dir.path(), folder_name).unwrap(), None, None); + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); let code = result.to_string(); // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index b51019c..9606de9 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -42,6 +42,9 @@ pub struct CollectedMetadata { impl CollectedMetadata { pub fn new() -> Self { - Self { routes: Vec::new(), structs: Vec::new() } + Self { + routes: Vec::new(), + structs: Vec::new(), + } } } diff --git a/crates/vespera_macro/src/method.rs b/crates/vespera_macro/src/method.rs index d820ccb..2a538aa 100644 --- a/crates/vespera_macro/src/method.rs +++ b/crates/vespera_macro/src/method.rs @@ -30,36 +30,74 @@ mod tests { #[case(HttpMethod::Head, "head")] #[case(HttpMethod::Options, "options")] #[case(HttpMethod::Trace, "trace")] - fn test_http_method_to_token_stream(#[case] method: HttpMethod, #[case] expected_method_name: &str) { + fn test_http_method_to_token_stream( + #[case] method: HttpMethod, + #[case] expected_method_name: &str, + ) { let result = http_method_to_token_stream(method); let code = result.to_string(); // Check that the code contains the expected method name // quote! generates "vespera :: axum :: routing :: get" format - assert!(code.contains(expected_method_name), "Code should contain method name: {}, got: {}", expected_method_name, code); + assert!( + code.contains(expected_method_name), + "Code should contain method name: {}, got: {}", + expected_method_name, + code + ); // Check that it contains the routing path - assert!(code.contains("routing"), "Code should contain 'routing', got: {}", code); + assert!( + code.contains("routing"), + "Code should contain 'routing', got: {}", + code + ); // Check that it contains the axum path - assert!(code.contains("axum"), "Code should contain 'axum', got: {}", code); + assert!( + code.contains("axum"), + "Code should contain 'axum', got: {}", + code + ); // Check that it contains the vespera path - assert!(code.contains("vespera"), "Code should contain 'vespera', got: {}", code); + assert!( + code.contains("vespera"), + "Code should contain 'vespera', got: {}", + code + ); } #[test] fn test_http_method_to_token_stream_all_methods() { // Test that all methods generate valid TokenStreams - let methods = vec![HttpMethod::Get, HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete, HttpMethod::Head, HttpMethod::Options, HttpMethod::Trace]; + let methods = vec![ + HttpMethod::Get, + HttpMethod::Post, + HttpMethod::Put, + HttpMethod::Patch, + HttpMethod::Delete, + HttpMethod::Head, + HttpMethod::Options, + HttpMethod::Trace, + ]; for method in methods { let result = http_method_to_token_stream(method.clone()); let code = result.to_string(); // Each should generate a valid TokenStream - assert!(!code.is_empty(), "TokenStream should not be empty for {:?}", method); - assert!(code.contains("routing"), "TokenStream should contain 'routing' for {:?}, got: {}", method, code); + assert!( + !code.is_empty(), + "TokenStream should not be empty for {:?}", + method + ); + assert!( + code.contains("routing"), + "TokenStream should contain 'routing' for {:?}, got: {}", + method, + code + ); } } } diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index e722af4..b371216 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -11,11 +11,17 @@ use crate::metadata::CollectedMetadata; use crate::parser::{build_operation_from_function, parse_enum_to_schema, parse_struct_to_schema}; /// Generate OpenAPI document from collected metadata -pub fn generate_openapi_doc_with_metadata(title: Option, version: Option, metadata: &CollectedMetadata) -> OpenApi { +pub fn generate_openapi_doc_with_metadata( + title: Option, + version: Option, + metadata: &CollectedMetadata, +) -> OpenApi { let mut paths: BTreeMap = BTreeMap::new(); 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(); + 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 and store struct definitions for struct_meta in &metadata.structs { @@ -28,11 +34,19 @@ pub fn generate_openapi_doc_with_metadata(title: Option, version: Option for struct_meta in &metadata.structs { 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, &struct_definitions), - syn::Item::Enum(enum_item) => parse_enum_to_schema(&enum_item, &known_schema_names, &struct_definitions), + syn::Item::Struct(struct_item) => { + 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) + } _ => { // Fallback to struct parsing for backward compatibility - parse_struct_to_schema(&syn::parse_str(&struct_meta.definition).unwrap(), &known_schema_names, &struct_definitions) + parse_struct_to_schema( + &syn::parse_str(&struct_meta.definition).unwrap(), + &known_schema_names, + &struct_definitions, + ) } }; let schema_name = struct_meta.name.clone(); @@ -45,7 +59,10 @@ pub fn generate_openapi_doc_with_metadata(title: Option, version: Option let content = match std::fs::read_to_string(&route_meta.file_path) { Ok(content) => content, Err(e) => { - eprintln!("Warning: Failed to read file {}: {}", route_meta.file_path, e); + eprintln!( + "Warning: Failed to read file {}: {}", + route_meta.file_path, e + ); continue; } }; @@ -53,7 +70,10 @@ pub fn generate_openapi_doc_with_metadata(title: Option, version: Option let file_ast = match syn::parse_file(&content) { Ok(ast) => ast, Err(e) => { - eprintln!("Warning: Failed to parse file {}: {}", route_meta.file_path, e); + eprintln!( + "Warning: Failed to parse file {}: {}", + route_meta.file_path, e + ); continue; } }; @@ -65,10 +85,30 @@ pub fn generate_openapi_doc_with_metadata(title: Option, version: Option let method = HttpMethod::from(route_meta.method.as_str()); // Build operation from function signature - let operation = build_operation_from_function(&fn_item.sig, &route_meta.path, &known_schema_names, &struct_definitions, route_meta.error_status.as_deref()); + let operation = build_operation_from_function( + &fn_item.sig, + &route_meta.path, + &known_schema_names, + &struct_definitions, + route_meta.error_status.as_deref(), + ); // Get or create PathItem - let path_item = paths.entry(route_meta.path.clone()).or_insert_with(|| PathItem { get: None, post: None, put: None, patch: None, delete: None, head: None, options: None, trace: None, parameters: None, summary: None, description: None }); + let path_item = paths + .entry(route_meta.path.clone()) + .or_insert_with(|| PathItem { + get: None, + post: None, + put: None, + patch: None, + delete: None, + head: None, + options: None, + trace: None, + parameters: None, + summary: None, + description: None, + }); // Set operation for the method path_item.set_operation(method, operation); @@ -78,7 +118,40 @@ pub fn generate_openapi_doc_with_metadata(title: Option, version: Option } // Build OpenAPI document - OpenApi { openapi: OpenApiVersion::V3_1_0, info: Info { title: title.unwrap_or_else(|| "API".to_string()), version: version.unwrap_or_else(|| "1.0.0".to_string()), description: None, terms_of_service: None, contact: None, license: None, summary: None }, servers: Some(vec![Server { url: "http://localhost:3000".to_string(), description: None, variables: None }]), paths, components: Some(Components { schemas: if schemas.is_empty() { None } else { Some(schemas) }, responses: None, parameters: None, examples: None, request_bodies: None, headers: None, security_schemes: None }), security: None, tags: None, external_docs: None } + OpenApi { + openapi: OpenApiVersion::V3_1_0, + info: Info { + title: title.unwrap_or_else(|| "API".to_string()), + version: version.unwrap_or_else(|| "1.0.0".to_string()), + description: None, + terms_of_service: None, + contact: None, + license: None, + summary: None, + }, + servers: Some(vec![Server { + url: "http://localhost:3000".to_string(), + description: None, + variables: None, + }]), + paths, + components: Some(Components { + schemas: if schemas.is_empty() { + None + } else { + Some(schemas) + }, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }), + security: None, + tags: None, + external_docs: None, + } } #[cfg(test)] @@ -108,7 +181,10 @@ mod tests { assert!(doc.paths.is_empty()); assert!(doc.components.as_ref().unwrap().schemas.is_none()); assert_eq!(doc.servers.as_ref().unwrap().len(), 1); - assert_eq!(doc.servers.as_ref().unwrap()[0].url, "http://localhost:3000"); + assert_eq!( + doc.servers.as_ref().unwrap()[0].url, + "http://localhost:3000" + ); } #[rstest] @@ -116,7 +192,12 @@ mod tests { #[case(Some("My API".to_string()), None, "My API", "1.0.0")] #[case(None, Some("2.0.0".to_string()), "API", "2.0.0")] #[case(Some("Test API".to_string()), Some("3.0.0".to_string()), "Test API", "3.0.0")] - fn test_generate_openapi_title_version(#[case] title: Option, #[case] version: Option, #[case] expected_title: &str, #[case] expected_version: &str) { + fn test_generate_openapi_title_version( + #[case] title: Option, + #[case] version: Option, + #[case] expected_title: &str, + #[case] expected_version: &str, + ) { let metadata = CollectedMetadata::new(); let doc = generate_openapi_doc_with_metadata(title, version, &metadata); @@ -138,7 +219,15 @@ pub fn get_users() -> String { let route_file = create_temp_file(&temp_dir, "users.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/users".to_string(), function_name: "get_users".to_string(), module_path: "test::users".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_users() -> String".to_string(), error_status: None }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "test::users".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_users() -> String".to_string(), + error_status: None, + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -152,7 +241,10 @@ pub fn get_users() -> String { #[test] fn test_generate_openapi_with_struct() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { name: "User".to_string(), definition: "struct User { id: i32, name: String }".to_string() }); + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: "struct User { id: i32, name: String }".to_string(), + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -164,7 +256,10 @@ pub fn get_users() -> String { #[test] fn test_generate_openapi_with_enum() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { name: "Status".to_string(), definition: "enum Status { Active, Inactive, Pending }".to_string() }); + metadata.structs.push(StructMetadata { + name: "Status".to_string(), + definition: "enum Status { Active, Inactive, Pending }".to_string(), + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -177,7 +272,10 @@ pub fn get_users() -> String { fn test_generate_openapi_with_enum_with_data() { // Test enum with data (tuple and struct variants) to ensure full coverage let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { name: "Message".to_string(), definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string() }); + metadata.structs.push(StructMetadata { + name: "Message".to_string(), + definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -198,8 +296,19 @@ pub fn get_status() -> Status { let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { name: "Status".to_string(), definition: "enum Status { Active, Inactive }".to_string() }); - metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/status".to_string(), function_name: "get_status".to_string(), module_path: "test::status_route".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_status() -> Status".to_string(), error_status: None }); + metadata.structs.push(StructMetadata { + name: "Status".to_string(), + definition: "enum Status { Active, Inactive }".to_string(), + }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/status".to_string(), + function_name: "get_status".to_string(), + module_path: "test::status_route".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_status() -> Status".to_string(), + error_status: None, + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -244,10 +353,25 @@ pub fn get_user() -> User { let route_file = create_temp_file(&temp_dir, "user_route.rs", route_content); let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { name: "User".to_string(), definition: "struct User { id: i32, name: String }".to_string() }); - metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/user".to_string(), function_name: "get_user".to_string(), module_path: "test::user_route".to_string(), file_path: route_file.to_string_lossy().to_string(), signature: "fn get_user() -> User".to_string(), error_status: None }); + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: "struct User { id: i32, name: String }".to_string(), + }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/user".to_string(), + function_name: "get_user".to_string(), + module_path: "test::user_route".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_user() -> User".to_string(), + error_status: None, + }); - let doc = generate_openapi_doc_with_metadata(Some("Test API".to_string()), Some("1.0.0".to_string()), &metadata); + let doc = generate_openapi_doc_with_metadata( + Some("Test API".to_string()), + Some("1.0.0".to_string()), + &metadata, + ); // Check struct schema assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -279,8 +403,24 @@ pub fn create_user() -> String { let route2_file = create_temp_file(&temp_dir, "create_user.rs", route2_content); let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { method: "GET".to_string(), path: "/users".to_string(), function_name: "get_users".to_string(), module_path: "test::users".to_string(), file_path: route1_file.to_string_lossy().to_string(), signature: "fn get_users() -> String".to_string(), error_status: None }); - metadata.routes.push(RouteMetadata { method: "POST".to_string(), path: "/users".to_string(), function_name: "create_user".to_string(), module_path: "test::create_user".to_string(), file_path: route2_file.to_string_lossy().to_string(), signature: "fn create_user() -> String".to_string(), error_status: None }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "test::users".to_string(), + file_path: route1_file.to_string_lossy().to_string(), + signature: "fn get_users() -> String".to_string(), + error_status: None, + }); + metadata.routes.push(RouteMetadata { + method: "POST".to_string(), + path: "/users".to_string(), + function_name: "create_user".to_string(), + module_path: "test::create_user".to_string(), + file_path: route2_file.to_string_lossy().to_string(), + signature: "fn create_user() -> String".to_string(), + error_status: None, + }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -320,7 +460,12 @@ pub fn create_user() -> String { false, // struct should not be added false, // route should not be added )] - fn test_generate_openapi_file_errors(#[case] struct_meta: Option, #[case] route_meta: Option, #[case] expect_struct: bool, #[case] expect_route: bool) { + fn test_generate_openapi_file_errors( + #[case] struct_meta: Option, + #[case] route_meta: Option, + #[case] expect_struct: bool, + #[case] expect_route: bool, + ) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let mut metadata = CollectedMetadata::new(); @@ -334,7 +479,8 @@ pub fn create_user() -> String { if let Some(mut route_m) = route_meta { // If file_path is empty, create invalid syntax file if route_m.file_path.is_empty() { - let invalid_file = create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); + let invalid_file = + create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); route_m.file_path = invalid_file.to_string_lossy().to_string(); } metadata.routes.push(route_m); diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 79f1d38..38369ce 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -6,10 +6,20 @@ use vespera_core::{ schema::{Schema, SchemaRef}, }; -use super::{parameters::parse_function_parameter, path::extract_path_parameters, request_body::parse_request_body, response::parse_return_type, schema::parse_type_to_schema_ref_with_schemas}; +use super::{ + parameters::parse_function_parameter, path::extract_path_parameters, + request_body::parse_request_body, response::parse_return_type, + schema::parse_type_to_schema_ref_with_schemas, +}; /// Build Operation from function signature -pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_schemas: &std::collections::HashMap, struct_definitions: &std::collections::HashMap, error_status: Option<&[u16]>) -> Operation { +pub fn build_operation_from_function( + sig: &syn::Signature, + path: &str, + known_schemas: &std::collections::HashMap, + struct_definitions: &std::collections::HashMap, + error_status: Option<&[u16]>, +) -> Operation { let path_params = extract_path_parameters(path); let mut parameters = Vec::new(); let mut request_body = None; @@ -43,27 +53,82 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch // For tuple types, match each path parameter with tuple element type for (idx, param_name) in path_params.iter().enumerate() { if let Some(elem_ty) = tuple.elems.get(idx) { - parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(elem_ty, known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + )), + example: None, + }); } else { // If tuple doesn't have enough elements, use String as default - parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&syn::parse_str::("String").unwrap(), known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &syn::parse_str::("String").unwrap(), + known_schemas, + struct_definitions, + )), + example: None, + }); } } } else { // Single path parameter if path_params.len() == 1 { - parameters.push(Parameter { name: path_params[0].clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&ty, known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: path_params[0].clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &ty, + known_schemas, + struct_definitions, + )), + example: None, + }); } else { // Multiple path parameters but single type - use String for all for param_name in &path_params { - parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&ty, known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &ty, + known_schemas, + struct_definitions, + )), + example: None, + }); } } } } else { // No Path extractor found, but path has parameters - use String as default for param_name in &path_params { - parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(&syn::parse_str::("String").unwrap(), known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + &syn::parse_str::("String").unwrap(), + known_schemas, + struct_definitions, + )), + example: None, + }); } } } @@ -85,7 +150,10 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch false }; - if !is_path_extractor && let Some(params) = parse_function_parameter(input, &path_params, known_schemas, struct_definitions) { + if !is_path_extractor + && let Some(params) = + parse_function_parameter(input, &path_params, known_schemas, struct_definitions) + { parameters.extend(params); } } @@ -96,10 +164,19 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch && let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() { let is_string = match ty.as_ref() { - Type::Path(type_path) => type_path.path.segments.last().map(|s| s.ident == "String" || s.ident == "str").unwrap_or(false), + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false), Type::Reference(type_ref) => { if let Type::Path(p) = type_ref.elem.as_ref() { - p.path.segments.last().map(|s| s.ident == "String" || s.ident == "str").unwrap_or(false) + p.path + .segments + .last() + .map(|s| s.ident == "String" || s.ident == "str") + .unwrap_or(false) } else { false } @@ -109,8 +186,19 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch if is_string { let mut content = BTreeMap::new(); - content.insert("text/plain".to_string(), MediaType { schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None, examples: None }); - request_body = Some(RequestBody { description: None, content, required: Some(true) }); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + examples: None, + }, + ); + request_body = Some(RequestBody { + description: None, + content, + required: Some(true), + }); } } @@ -120,7 +208,16 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch // Add additional error status codes from error_status attribute if let Some(status_codes) = error_status { // Find the error response schema (usually 400 or the first error response) - let error_schema = responses.iter().find(|(code, _)| code != &&"200".to_string()).and_then(|(_, resp)| resp.content.as_ref()?.get("application/json")?.schema.clone()); + let error_schema = responses + .iter() + .find(|(code, _)| code != &&"200".to_string()) + .and_then(|(_, resp)| { + resp.content + .as_ref()? + .get("application/json")? + .schema + .clone() + }); if let Some(schema) = error_schema { for &status_code in status_codes { @@ -128,15 +225,39 @@ pub fn build_operation_from_function(sig: &syn::Signature, path: &str, known_sch // Only add if not already present responses.entry(status_str).or_insert_with(|| { let mut err_content = BTreeMap::new(); - err_content.insert("application/json".to_string(), MediaType { schema: Some(schema.clone()), example: None, examples: None }); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema.clone()), + example: None, + examples: None, + }, + ); - Response { description: "Error response".to_string(), headers: None, content: Some(err_content) } + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + } }); } } } - Operation { operation_id: Some(sig.ident.to_string()), tags: None, summary: None, description: None, parameters: if parameters.is_empty() { None } else { Some(parameters) }, request_body, responses, security: None } + Operation { + operation_id: Some(sig.ident.to_string()), + tags: None, + summary: None, + description: None, + parameters: if parameters.is_empty() { + None + } else { + Some(parameters) + }, + request_body, + responses, + security: None, + } } #[cfg(test)] @@ -148,7 +269,8 @@ mod tests { #[test] fn test_build_operation_string_body_fallback() { let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); - let op = build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); + let op = + build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); // Ensure body is set as text/plain let body = op.request_body.as_ref().expect("request body expected"); diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index be2dab9..9e43919 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -6,12 +6,20 @@ use vespera_core::{ schema::{Schema, SchemaRef, SchemaType}, }; -use super::schema::{extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, parse_type_to_schema_ref_with_schemas, rename_field}; +use super::schema::{ + extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, + parse_type_to_schema_ref_with_schemas, rename_field, +}; /// 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> { +pub fn parse_function_parameter( + arg: &FnArg, + path_params: &[String], + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> Option> { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { pat, ty, .. }) => { @@ -50,7 +58,14 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem if inner_ident_str == "TypedHeader" { // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { name: param_name.replace("_", "-"), r#in: ParameterLocation::Header, description: None, required: Some(false), schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None }]); + return Some(vec![Parameter { + name: param_name.replace("_", "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(false), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + }]); } } } @@ -69,7 +84,8 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem "Path" => { // Path extractor - use path parameter name from route if available if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() { // Check if inner type is a tuple (e.g., Path<(String, String, String)>) if let Type::Tuple(tuple) = inner_ty { @@ -80,7 +96,20 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem // Match tuple elements with path parameters for (idx, elem_ty) in tuple_elems.iter().enumerate() { if let Some(param_name) = path_params.get(idx) { - parameters.push(Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(elem_ty, known_schemas, struct_definitions)), example: None }); + parameters.push(Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some( + parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + ), + ), + example: None, + }); } } @@ -94,14 +123,26 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem return None; } let name = path_params[0].clone(); - return Some(vec![Parameter { name, r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); + return Some(vec![Parameter { + name, + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); } } } "Query" => { // Query extractor if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() { // Check if it's HashMap or BTreeMap - ignore these if is_map_type(inner_ty) { @@ -109,7 +150,11 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem } // 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) { + if let Some(struct_params) = parse_query_struct_to_parameters( + inner_ty, + known_schemas, + struct_definitions, + ) { return Some(struct_params); } @@ -125,25 +170,55 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem } // Otherwise, treat as single parameter - return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Query, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); + return Some(vec![Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Query, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); } } "Header" => { // Header extractor if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(syn::GenericArgument::Type(inner_ty)) = + args.args.first() { // Ignore primitive-like headers if is_primitive_like(inner_ty) { return None; } - return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Header, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions)), example: None }]); + return Some(vec![Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); } } "TypedHeader" => { // TypedHeader extractor (axum::TypedHeader) // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { name: param_name.replace("_", "-"), r#in: ParameterLocation::Header, description: None, required: Some(true), schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), example: None }]); + return Some(vec![Parameter { + name: param_name.replace("_", "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + }]); } "Json" => { // Json extractor - this will be handled as RequestBody @@ -156,7 +231,18 @@ pub fn parse_function_parameter(arg: &FnArg, path_params: &[String], known_schem // Check if it's a path parameter (by name match) - for non-extractor cases if path_params.contains(¶m_name) { - return Some(vec![Parameter { name: param_name.clone(), r#in: ParameterLocation::Path, description: None, required: Some(true), schema: Some(parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions)), example: None }]); + return Some(vec![Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + )), + example: None, + }]); } // Bare primitive without extractor is ignored (cannot infer location) @@ -196,7 +282,11 @@ fn is_primitive_like(ty: &Type) -> bool { false } -fn is_known_type(ty: &Type, known_schemas: &HashMap, struct_definitions: &HashMap) -> bool { +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; @@ -237,7 +327,11 @@ fn is_known_type(ty: &Type, known_schemas: &HashMap, struct_defi /// 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> { +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; @@ -261,7 +355,11 @@ fn parse_query_struct_to_parameters(ty: &Type, known_schemas: &HashMap "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/") && let Some(struct_def) = struct_definitions.get(type_name) - && let Ok(nested_struct_item) = syn::parse_str::(struct_def) + && 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); + let nested_schema = parse_struct_to_schema( + &nested_struct_item, + known_schemas, + struct_definitions, + ); field_schema = SchemaRef::Inline(Box::new(nested_schema)); } } @@ -310,19 +418,32 @@ fn parse_query_struct_to_parameters(ty: &Type, known_schemas: &HashMap SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), + 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 }); + parameters.push(Parameter { + name: field_name, + r#in: ParameterLocation::Query, + description: None, + required: Some(required), + schema: Some(final_schema), + example: None, + }); } } @@ -473,23 +594,39 @@ mod tests { vec![vec![ParameterLocation::Header]], "header_custom" )] - fn test_parse_function_parameter_cases(#[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, #[case] suffix: &str) { + fn test_parse_function_parameter_cases( + #[case] func_src: &str, + #[case] path_params: Vec, + #[case] expected_locations: Vec>, + #[case] suffix: &str, + ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); let mut parameters = Vec::new(); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); - let expected = expected_locations.get(idx).unwrap_or_else(|| expected_locations.last().unwrap()); + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + let expected = expected_locations + .get(idx) + .unwrap_or_else(|| expected_locations.last().unwrap()); if expected.is_empty() { - assert!(result.is_none(), "Expected None at arg index {}, func: {}", idx, func_src); + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}", + idx, + func_src + ); continue; } let params = result.as_ref().expect("Expected Some parameters"); let got_locs: Vec = params.iter().map(|p| p.r#in.clone()).collect(); - assert_eq!(got_locs, *expected, "Location mismatch at arg index {idx}, func: {func_src}"); + assert_eq!( + got_locs, *expected, + "Location mismatch at arg index {idx}, func: {func_src}" + ); parameters.extend(params.clone()); } with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { @@ -526,19 +663,35 @@ mod tests { "fn test((x, y): (i32, i32)) {}", vec![], )] - fn test_parse_function_parameter_wrong_cases(#[case] func_src: &str, #[case] path_params: Vec) { + fn test_parse_function_parameter_wrong_cases( + #[case] func_src: &str, + #[case] path_params: Vec, + ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); // Provide custom types for header/query known schemas/structs let mut struct_definitions = struct_definitions; - struct_definitions.insert("User".to_string(), "pub struct User { pub id: i32 }".to_string()); + struct_definitions.insert( + "User".to_string(), + "pub struct User { pub id: i32 }".to_string(), + ); let mut known_schemas = known_schemas; - known_schemas.insert("CustomHeader".to_string(), "#/components/schemas/CustomHeader".to_string()); + known_schemas.insert( + "CustomHeader".to_string(), + "#/components/schemas/CustomHeader".to_string(), + ); for (idx, arg) in func.sig.inputs.iter().enumerate() { - let result = parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); - assert!(result.is_none(), "Expected None at arg index {}, func: {}, got: {:?}", idx, func_src, result); + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}, got: {:?}", + idx, + func_src, + result + ); } } @@ -587,9 +740,19 @@ mod tests { #[case("Vec", HashMap::new(), HashMap::new(), true)] // Vec with known inner type #[case("Option", HashMap::new(), HashMap::new(), true)] // Option with known inner type #[case("UnknownType", HashMap::new(), HashMap::new(), false)] // unknown type - fn test_is_known_type(#[case] type_str: &str, #[case] known_schemas: HashMap, #[case] struct_definitions: HashMap, #[case] expected: bool) { + fn test_is_known_type( + #[case] type_str: &str, + #[case] known_schemas: HashMap, + #[case] struct_definitions: HashMap, + #[case] expected: bool, + ) { let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!(is_known_type(&ty, &known_schemas, &struct_definitions), expected, "Type: {}", type_str); + assert_eq!( + is_known_type(&ty, &known_schemas, &struct_definitions), + expected, + "Type: {}", + type_str + ); } #[test] diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 5a87162..f411be8 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -7,14 +7,23 @@ use super::schema::parse_type_to_schema_ref_with_schemas; fn is_string_like(ty: &Type) -> bool { match ty { - Type::Path(type_path) => type_path.path.segments.last().map(|seg| seg.ident == "String" || seg.ident == "str").unwrap_or(false), + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|seg| seg.ident == "String" || seg.ident == "str") + .unwrap_or(false), Type::Reference(type_ref) => is_string_like(&type_ref.elem), _ => false, } } /// Analyze function signature and extract RequestBody -pub fn parse_request_body(arg: &FnArg, known_schemas: &std::collections::HashMap, struct_definitions: &std::collections::HashMap) -> Option { +pub fn parse_request_body( + arg: &FnArg, + known_schemas: &std::collections::HashMap, + struct_definitions: &std::collections::HashMap, +) -> Option { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { ty, .. }) => { @@ -32,19 +41,46 @@ pub fn parse_request_body(arg: &FnArg, known_schemas: &std::collections::HashMap && let syn::PathArguments::AngleBracketed(args) = &segment.arguments && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let schema = parse_type_to_schema_ref_with_schemas(inner_ty, known_schemas, struct_definitions); + 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(), MediaType { schema: Some(schema), example: None, examples: None }); - return Some(RequestBody { description: None, required: Some(true), content }); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); } } if is_string_like(ty.as_ref()) { - let schema = parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); + let schema = + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); let mut content = BTreeMap::new(); - content.insert("text/plain".to_string(), MediaType { schema: Some(schema), example: None, examples: None }); + content.insert( + "text/plain".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); - return Some(RequestBody { description: None, required: Some(true), content }); + return Some(RequestBody { + description: None, + required: Some(true), + content, + }); } None } @@ -64,7 +100,11 @@ mod tests { #[case::string("fn test(just_string: String) {}", true, "string")] #[case::str("fn test(just_str: &str) {}", true, "str")] #[case::i32("fn test(just_i32: i32) {}", false, "i32")] - fn test_parse_request_body_cases(#[case] func_src: &str, #[case] has_body: bool, #[case] suffix: &str) { + fn test_parse_request_body_cases( + #[case] func_src: &str, + #[case] has_body: bool, + #[case] suffix: &str, + ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let arg = func.sig.inputs.first().unwrap(); let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()); @@ -78,9 +118,13 @@ mod tests { fn test_parse_request_body_text_plain_schema() { let func: syn::ItemFn = syn::parse_str("fn test(body: &str) {}").unwrap(); let arg = func.sig.inputs.first().unwrap(); - let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()).expect("expected request body"); + let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()) + .expect("expected request body"); - let media = body.content.get("text/plain").expect("expected text/plain content"); + let media = body + .content + .get("text/plain") + .expect("expected text/plain content"); if let SchemaRef::Inline(schema) = media.schema.as_ref().expect("schema expected") { assert_eq!(schema.schema_type, Some(SchemaType::String)); diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 3de9bb5..088327d 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -57,7 +57,10 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { 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)) + && 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); @@ -78,7 +81,8 @@ fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { let path = &type_path.path; 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")); + 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 @@ -113,7 +117,11 @@ fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option (Type, Option, struct_definitions: &HashMap) -> BTreeMap { +pub fn parse_return_type( + return_type: &ReturnType, + known_schemas: &HashMap, + struct_definitions: &HashMap, +) -> BTreeMap { let mut responses = BTreeMap::new(); match return_type { ReturnType::Default => { // No return type - just 200 with no content - responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: None, content: None }); + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: None, + content: None, + }, + ); } ReturnType::Type(_, ty) => { // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); - let ok_schema = parse_type_to_schema_ref_with_schemas(&ok_payload_ty, known_schemas, struct_definitions); + let ok_schema = parse_type_to_schema_ref_with_schemas( + &ok_payload_ty, + known_schemas, + struct_definitions, + ); let mut ok_content = BTreeMap::new(); - ok_content.insert("application/json".to_string(), MediaType { schema: Some(ok_schema), example: None, examples: None }); - - responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: ok_headers, content: Some(ok_content) }); + ok_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(ok_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: ok_headers, + content: Some(ok_content), + }, + ); // Handle error response // 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_with_schemas(&error_type, known_schemas, struct_definitions); + 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(), MediaType { schema: Some(err_schema), example: None, examples: None }); - - responses.insert(status_code.to_string(), Response { description: "Error response".to_string(), headers: None, content: Some(err_content) }); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(err_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + status_code.to_string(), + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + }, + ); } else { // 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_with_schemas(err_ty_unwrapped, known_schemas, struct_definitions); + 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(), MediaType { schema: Some(err_schema), example: None, examples: None }); - - responses.insert("400".to_string(), Response { description: "Error response".to_string(), headers: None, content: Some(err_content) }); + err_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(err_schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "400".to_string(), + Response { + description: "Error response".to_string(), + headers: None, + content: Some(err_content), + }, + ); } } else { // Not a Result type - regular response // Unwrap Json if present let unwrapped_ty = unwrap_json(ty); - let schema = parse_type_to_schema_ref_with_schemas(unwrapped_ty, known_schemas, struct_definitions); + 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(), MediaType { schema: Some(schema), example: None, examples: None }); - - responses.insert("200".to_string(), Response { description: "Successful response".to_string(), headers: None, content: Some(content) }); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + headers: None, + content: Some(content), + }, + ); } } } @@ -193,7 +284,10 @@ mod tests { #[case("-> Result", "Result")] // Result with same types #[case("-> Result", "Result")] // Result with different types #[case("-> Result, String>", "Result, String>")] // Result with Json wrapper - #[case("-> Result", "Result")] // Result with status code tuple + #[case( + "-> Result", + "Result" + )] // Result with status code tuple #[case("-> &str", "&str")] // Reference return type #[case("-> Result<&str, String>", "Result<&str, String>")] // Result with reference fn test_parse_return_type(#[case] return_type_str: &str, #[case] expected_type: &str) { @@ -205,7 +299,8 @@ mod tests { } else { // Parse the return type from string 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 parsed: syn::Signature = + syn::parse_str(&full_signature).expect("Failed to parse return type"); parsed.output }; diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 196a22d..1746c59 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -131,7 +131,8 @@ pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String Some("SCREAMING_SNAKE_CASE") => { // Convert to SCREAMING_SNAKE_CASE // If already in SCREAMING_SNAKE_CASE format, return as is - if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') { + if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') + { return field_name.to_string(); } // First convert to snake_case if needed, then uppercase @@ -153,7 +154,11 @@ pub(super) fn rename_field(field_name: &str, rename_all: Option<&str>) -> String // First convert to kebab-case if needed, then uppercase let mut kebab_case = String::new(); for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 && !kebab_case.ends_with('-') && !kebab_case.ends_with('_') { + if ch.is_uppercase() + && i > 0 + && !kebab_case.ends_with('-') + && !kebab_case.ends_with('_') + { kebab_case.push('-'); } if ch == '_' { @@ -170,12 +175,19 @@ pub(super) 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 { +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); // Check if all variants are unit variants - let all_unit = enum_item.variants.iter().all(|v| matches!(v.fields, syn::Fields::Unit)); + let all_unit = enum_item + .variants + .iter() + .all(|v| matches!(v.fields, syn::Fields::Unit)); if all_unit { // Simple enum with string values @@ -195,7 +207,15 @@ pub fn parse_enum_to_schema(enum_item: &syn::ItemEnum, known_schemas: &HashMap { // Unit variant: {"const": "VariantName"} - Schema { r#enum: Some(vec![serde_json::Value::String(variant_key)]), ..Schema::string() } + Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key)]), + ..Schema::string() + } } syn::Fields::Unnamed(fields_unnamed) => { // Tuple variant: {"VariantName": } @@ -223,19 +246,28 @@ pub fn parse_enum_to_schema(enum_item: &syn::ItemEnum, known_schemas: &HashMap { @@ -263,18 +302,26 @@ pub fn parse_enum_to_schema(enum_item: &syn::ItemEnum, known_schemas: &HashMap, struct_definitions: &HashMap) -> 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(); @@ -326,7 +400,11 @@ pub fn parse_struct_to_schema(struct_item: &syn::ItemStruct, known_schemas: &Has 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()); + 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) { @@ -338,7 +416,8 @@ pub fn parse_struct_to_schema(struct_item: &syn::ItemStruct, known_schemas: &Has let field_type = &field.ty; - let schema_ref = 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); @@ -367,7 +446,20 @@ pub fn parse_struct_to_schema(struct_item: &syn::ItemStruct, known_schemas: &Has } } - 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() } + 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() + } } fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type { @@ -408,7 +500,21 @@ pub(super) fn is_primitive_type(ty: &Type) -> bool { let path = &type_path.path; if path.segments.len() == 1 { let ident = path.segments[0].ident.to_string(); - matches!(ident.as_str(), "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64" | "bool" | "String" | "str") + matches!( + ident.as_str(), + "i8" | "i16" + | "i32" + | "i64" + | "u8" + | "u16" + | "u32" + | "u64" + | "f32" + | "f64" + | "bool" + | "String" + | "str" + ) } else { false } @@ -417,11 +523,19 @@ pub(super) fn is_primitive_type(ty: &Type) -> bool { } } -pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap, struct_definitions: &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) } -pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &HashMap, struct_definitions: &HashMap) -> SchemaRef { +pub(super) 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; @@ -438,7 +552,11 @@ pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &H 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 { @@ -454,17 +572,30 @@ pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &H // HashMap or BTreeMap -> object with additionalProperties // K is typically String, we use V as the value type if args.args.len() >= 2 - && let (Some(syn::GenericArgument::Type(_key_ty)), Some(syn::GenericArgument::Type(value_ty))) = (args.args.get(0), args.args.get(1)) + && let ( + 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); + 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!({})), + 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() })); + return SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + additional_properties: Some(additional_props_value), + ..Schema::object() + })); } } _ => {} @@ -473,7 +604,9 @@ pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &H // Handle primitive types match ident_str.as_str() { - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => SchemaRef::Inline(Box::new(Schema::integer())), + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { + SchemaRef::Inline(Box::new(Schema::integer())) + } "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), @@ -502,16 +635,41 @@ pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &H && 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(); + 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(); + 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); + field.ty = substitute_type( + &field.ty, + &generic_params, + &concrete_types, + ); } } @@ -520,7 +678,11 @@ pub(super) fn parse_type_to_schema_ref_with_schemas(ty: &Type, known_schemas: &H parsed.generics.where_clause = None; // Parse the substituted struct to schema (inline) - let schema = parse_struct_to_schema(&parsed, known_schemas, struct_definitions); + let schema = parse_struct_to_schema( + &parsed, + known_schemas, + struct_definitions, + ); return SchemaRef::Inline(Box::new(schema)); } } @@ -554,7 +716,11 @@ mod tests { #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] #[case("Option", Some(SchemaType::String), false)] // nullable check - fn test_parse_type_to_schema_ref_cases(#[case] ty_src: &str, #[case] expected_type: Option, #[case] expect_additional_props: bool) { + fn test_parse_type_to_schema_ref_cases( + #[case] ty_src: &str, + #[case] expected_type: Option, + #[case] expect_additional_props: bool, + ) { let ty: syn::Type = syn::parse_str(ty_src).unwrap(); let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); if let SchemaRef::Inline(schema) = schema_ref { @@ -607,11 +773,22 @@ mod tests { vec!["first_item", "second_item"], "simple_snake" )] - fn test_parse_enum_to_schema_unit_variants(#[case] enum_src: &str, #[case] expected_type: SchemaType, #[case] expected_enum: Vec<&str>, #[case] suffix: &str) { + fn test_parse_enum_to_schema_unit_variants( + #[case] enum_src: &str, + #[case] expected_type: SchemaType, + #[case] expected_enum: Vec<&str>, + #[case] suffix: &str, + ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); assert_eq!(schema.schema_type, Some(expected_type)); - let got = schema.clone().r#enum.unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); + let got = schema + .clone() + .r#enum + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect::>(); assert_eq!(got, expected_enum); with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { assert_debug_snapshot!(schema); @@ -652,7 +829,13 @@ mod tests { 0, // not an array; ignore prefix_items length "named_object" )] - fn test_parse_enum_to_schema_tuple_and_named_variants(#[case] enum_src: &str, #[case] expected_one_of_len: usize, #[case] expected_inner_type: Option, #[case] expected_prefix_items_len: usize, #[case] suffix: &str) { + fn test_parse_enum_to_schema_tuple_and_named_variants( + #[case] enum_src: &str, + #[case] expected_one_of_len: usize, + #[case] expected_inner_type: Option, + #[case] expected_prefix_items_len: usize, + #[case] suffix: &str, + ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); let one_of = schema.clone().one_of.expect("one_of missing"); @@ -668,7 +851,10 @@ mod tests { if let SchemaRef::Inline(array_schema) = inner_schema { assert_eq!(array_schema.schema_type, Some(SchemaType::Array)); if expected_prefix_items_len > 0 { - assert_eq!(array_schema.prefix_items.as_ref().unwrap().len(), expected_prefix_items_len); + assert_eq!( + array_schema.prefix_items.as_ref().unwrap().len(), + expected_prefix_items_len + ); } } else { panic!("Expected inline array schema"); @@ -680,7 +866,13 @@ mod tests { let inner_props = inner_obj.properties.as_ref().unwrap(); assert!(inner_props.contains_key("id")); assert!(inner_props.contains_key("note")); - assert!(inner_obj.required.as_ref().unwrap().contains(&"id".to_string())); + assert!( + inner_obj + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); } else { panic!("Expected inline object schema"); } @@ -709,7 +901,12 @@ mod tests { SchemaType::String, "Ready" )] - fn test_parse_enum_to_schema_mixed_unit_variant(#[case] enum_src: &str, #[case] expected_one_of_len: usize, #[case] expected_unit_type: SchemaType, #[case] expected_unit_value: &str) { + fn test_parse_enum_to_schema_mixed_unit_variant( + #[case] enum_src: &str, + #[case] expected_one_of_len: usize, + #[case] expected_unit_type: SchemaType, + #[case] expected_unit_value: &str, + ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); @@ -743,7 +940,10 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj.properties.as_ref().expect("variant props missing"); + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); assert!(props.contains_key("data-item")); } @@ -765,7 +965,10 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj.properties.as_ref().expect("variant props missing"); + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); let inner = match props.get("detail").expect("variant key missing") { SchemaRef::Inline(s) => s, _ => panic!("Expected inline inner schema"), @@ -794,7 +997,10 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj.properties.as_ref().expect("variant props missing"); + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); assert!(props.contains_key("Explicit")); assert!(!props.contains_key("data_item")); } @@ -818,8 +1024,15 @@ mod tests { SchemaRef::Inline(s) => s, _ => panic!("Expected inline schema"), }; - let props = variant_obj.properties.as_ref().expect("variant props missing"); - let inner = match props.get("detail").or_else(|| props.get("Detail")).expect("variant key missing") { + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + let inner = match props + .get("detail") + .or_else(|| props.get("Detail")) + .expect("variant key missing") + { SchemaRef::Inline(s) => s, _ => panic!("Expected inline inner schema"), }; @@ -843,8 +1056,20 @@ mod tests { let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("id")); assert!(props.contains_key("name")); - assert!(schema.required.as_ref().unwrap().contains(&"id".to_string())); - assert!(!schema.required.as_ref().unwrap().contains(&"name".to_string())); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); + assert!( + !schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); } #[test] @@ -883,7 +1108,13 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_empty_path_and_reference() { // Empty path segments returns object - let ty = Type::Path(syn::TypePath { qself: None, path: syn::Path { leading_colon: None, segments: syn::punctuated::Punctuated::new() } }); + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); assert!(matches!(schema_ref, SchemaRef::Inline(_))); @@ -918,7 +1149,10 @@ mod tests { known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string()); let mut struct_definitions = HashMap::new(); - struct_definitions.insert("Wrapper".to_string(), "struct Wrapper { value: T }".to_string()); + struct_definitions.insert( + "Wrapper".to_string(), + "struct Wrapper { value: T }".to_string(), + ); let ty: syn::Type = syn::parse_str("Wrapper").unwrap(); let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions); @@ -938,7 +1172,10 @@ mod tests { #[rstest] #[case("$invalid", "String")] - fn test_substitute_type_parse_failure_uses_original(#[case] invalid: &str, #[case] concrete_src: &str) { + fn test_substitute_type_parse_failure_uses_original( + #[case] invalid: &str, + #[case] concrete_src: &str, + ) { use proc_macro2::TokenStream; use std::str::FromStr; @@ -957,11 +1194,21 @@ mod tests { } #[rstest] - #[case("HashMap", true, None, Some("#/components/schemas/Value"))] + #[case( + "HashMap", + true, + None, + Some("#/components/schemas/Value") + )] #[case("Result", false, Some(SchemaType::Object), None)] #[case("crate::Value", false, None, None)] #[case("(i32, bool)", false, Some(SchemaType::Object), None)] - fn test_parse_type_to_schema_ref_additional_cases(#[case] ty_src: &str, #[case] expect_additional_props: bool, #[case] expected_type: Option, #[case] expected_ref: Option<&str>) { + fn test_parse_type_to_schema_ref_additional_cases( + #[case] ty_src: &str, + #[case] expect_additional_props: bool, + #[case] expected_type: Option, + #[case] expected_ref: Option<&str>, + ) { let mut known_schemas = HashMap::new(); known_schemas.insert("Value".to_string(), "Value".to_string()); @@ -972,7 +1219,10 @@ mod tests { let SchemaRef::Inline(schema) = schema_ref else { panic!("Expected inline schema for {}", ty_src); }; - let additional = schema.additional_properties.as_ref().expect("additional_properties missing"); + let additional = schema + .additional_properties + .as_ref() + .expect("additional_properties missing"); assert_eq!(additional.get("$ref").unwrap(), expected); } None => match schema_ref { @@ -1052,7 +1302,11 @@ mod tests { #[case("firstName", None, "firstName")] #[case("LastName", None, "LastName")] #[case("user-id", None, "user-id")] - fn test_rename_field(#[case] field_name: &str, #[case] rename_all: Option<&str>, #[case] expected: &str) { + fn test_rename_field( + #[case] field_name: &str, + #[case] rename_all: Option<&str>, + #[case] expected: &str, + ) { assert_eq!(rename_field(field_name, rename_all), expected); } } diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 538b51b..75f8239 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -9,9 +9,25 @@ pub struct RouteInfo { pub fn check_route_by_meta(meta: &syn::Meta) -> bool { match meta { - syn::Meta::List(meta_list) => (meta_list.path.segments.len() == 2 && meta_list.path.segments[0].ident == "vespera" && meta_list.path.segments[1].ident == "route") || (meta_list.path.segments.len() == 1 && meta_list.path.segments[0].ident == "route"), - syn::Meta::Path(path) => (path.segments.len() == 2 && path.segments[0].ident == "vespera" && path.segments[1].ident == "route") || (path.segments.len() == 1 && path.segments[0].ident == "route"), - syn::Meta::NameValue(meta_nv) => (meta_nv.path.segments.len() == 2 && meta_nv.path.segments[0].ident == "vespera" && meta_nv.path.segments[1].ident == "route") || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route"), + syn::Meta::List(meta_list) => { + (meta_list.path.segments.len() == 2 + && meta_list.path.segments[0].ident == "vespera" + && meta_list.path.segments[1].ident == "route") + || (meta_list.path.segments.len() == 1 + && meta_list.path.segments[0].ident == "route") + } + syn::Meta::Path(path) => { + (path.segments.len() == 2 + && path.segments[0].ident == "vespera" + && path.segments[1].ident == "route") + || (path.segments.len() == 1 && path.segments[0].ident == "route") + } + syn::Meta::NameValue(meta_nv) => { + (meta_nv.path.segments.len() == 2 + && meta_nv.path.segments[0].ident == "vespera" + && meta_nv.path.segments[1].ident == "route") + || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route") + } } } @@ -23,37 +39,71 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { syn::Meta::List(meta_list) => { // Try to parse as RouteArgs if let Ok(route_args) = meta_list.parse_args::() { - let method = route_args.method.as_ref().map(syn::Ident::to_string).unwrap_or_else(|| "get".to_string()); + let method = route_args + .method + .as_ref() + .map(syn::Ident::to_string) + .unwrap_or_else(|| "get".to_string()); let path = route_args.path.as_ref().map(syn::LitStr::value); // Parse error_status array if present let error_status = route_args.error_status.as_ref().and_then(|array| { let mut status_codes = Vec::new(); for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. }) = elem + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = elem && let Ok(code) = lit_int.base10_parse::() { status_codes.push(code); } } - if status_codes.is_empty() { None } else { Some(status_codes) } + if status_codes.is_empty() { + None + } else { + Some(status_codes) + } }); - return Some(RouteInfo { method, path, error_status }); + return Some(RouteInfo { + method, + path, + error_status, + }); } } // Try to parse as Meta::NameValue (e.g., #[route = "patch"]) syn::Meta::NameValue(meta_nv) => { - if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = &meta_nv.value { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { let method_str = lit_str.value().to_lowercase(); - if method_str == "get" || method_str == "post" || method_str == "put" || method_str == "patch" || method_str == "delete" || method_str == "head" || method_str == "options" { - return Some(RouteInfo { method: method_str, path: None, error_status: None }); + if method_str == "get" + || method_str == "post" + || method_str == "put" + || method_str == "patch" + || method_str == "delete" + || method_str == "head" + || method_str == "options" + { + return Some(RouteInfo { + method: method_str, + path: None, + error_status: None, + }); } } } // Try to parse as Meta::Path (e.g., #[route]) syn::Meta::Path(_) => { - return Some(RouteInfo { method: "get".to_string(), path: None, error_status: None }); + return Some(RouteInfo { + method: "get".to_string(), + path: None, + error_status: None, + }); } } } @@ -111,7 +161,11 @@ mod tests { fn test_check_route_by_meta(#[case] attr_str: &str, #[case] expected: bool) { let meta = parse_meta_from_attr(attr_str); let result = check_route_by_meta(&meta); - assert_eq!(result, expected, "Failed for attribute: {}, expected: {}", attr_str, expected); + assert_eq!( + result, expected, + "Failed for attribute: {}, expected: {}", + attr_str, expected + ); } fn parse_attrs_from_code(code: &str) -> Vec { @@ -177,20 +231,44 @@ mod tests { #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] // method None, path None #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] // method Some, path None #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] // method Some, path Some - fn test_extract_route_info(#[case] code: &str, #[case] expected: Option<(String, Option, Option>)>) { + fn test_extract_route_info( + #[case] code: &str, + #[case] expected: Option<(String, Option, Option>)>, + ) { let attrs = parse_attrs_from_code(code); let result = extract_route_info(&attrs); match expected { Some((exp_method, exp_path, exp_error_status)) => { - assert!(result.is_some(), "Expected Some but got None for code: {}", code); + assert!( + result.is_some(), + "Expected Some but got None for code: {}", + code + ); let route_info = result.unwrap(); - assert_eq!(route_info.method, exp_method, "Method mismatch for code: {}", code); - assert_eq!(route_info.path, exp_path, "Path mismatch for code: {}", code); - assert_eq!(route_info.error_status, exp_error_status, "Error status mismatch for code: {}", code); + assert_eq!( + route_info.method, exp_method, + "Method mismatch for code: {}", + code + ); + assert_eq!( + route_info.path, exp_path, + "Path mismatch for code: {}", + code + ); + assert_eq!( + route_info.error_status, exp_error_status, + "Error status mismatch for code: {}", + code + ); } None => { - assert!(result.is_none(), "Expected None but got Some({:?}) for code: {}", result, code); + assert!( + result.is_none(), + "Expected None but got Some({:?}) for code: {}", + result, + code + ); } } } diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index 6479722..950dd7b 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -17,5 +17,12 @@ pub struct TestStruct { /// Create the application router for testing pub fn create_app() -> axum::Router { - vespera!(openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", redoc_url = "/redoc").with_state(Arc::new(AppState { config: "test".to_string() })) + vespera!( + openapi = ["examples/axum-example/openapi.json", "openapi.json"], + docs_url = "/docs", + redoc_url = "/redoc" + ) + .with_state(Arc::new(AppState { + config: "test".to_string(), + })) } diff --git a/examples/axum-example/src/routes/error.rs b/examples/axum-example/src/routes/error.rs index 656f732..4b8982a 100644 --- a/examples/axum-example/src/routes/error.rs +++ b/examples/axum-example/src/routes/error.rs @@ -24,22 +24,42 @@ impl IntoResponse for ErrorResponse2 { #[vespera::route()] pub async fn error_endpoint() -> Result<&'static str, Json> { - Err(Json(ErrorResponse { error: "Internal server error".to_string(), code: 500 })) + Err(Json(ErrorResponse { + error: "Internal server error".to_string(), + code: 500, + })) } #[vespera::route(path = "/error-with-status")] -pub async fn error_endpoint_with_status_code() -> Result<&'static str, (StatusCode, Json)> { - Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Internal server error".to_string(), code: 500 }))) +pub async fn error_endpoint_with_status_code() +-> Result<&'static str, (StatusCode, Json)> { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + code: 500, + }), + )) } #[vespera::route(path = "/error2")] pub async fn error_endpoint2() -> Result<&'static str, ErrorResponse2> { - Err(ErrorResponse2 { error: "Internal server error".to_string(), code: 500 }) + Err(ErrorResponse2 { + error: "Internal server error".to_string(), + code: 500, + }) } #[vespera::route(path = "/error-with-status2", error_status = [500, 400, 404])] -pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusCode, ErrorResponse2)> { - Err((StatusCode::INTERNAL_SERVER_ERROR, ErrorResponse2 { error: "Internal server error".to_string(), code: 500 })) +pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusCode, ErrorResponse2)> +{ + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorResponse2 { + error: "Internal server error".to_string(), + code: 500, + }, + )) } #[vespera::route(path = "/header-map")] @@ -50,7 +70,8 @@ pub async fn header_map_endpoint() -> Result<(HeaderMap, &'static str), ErrorRes } #[vespera::route(path = "/header-map2")] -pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static str), ErrorResponse2> { +pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static str), ErrorResponse2> +{ let headers = HeaderMap::new(); println!("headers: {:?}", headers); Ok((StatusCode::INTERNAL_SERVER_ERROR, headers, "ok")) diff --git a/examples/axum-example/src/routes/foo/mod.rs b/examples/axum-example/src/routes/foo/mod.rs index f82bc3b..9981578 100644 --- a/examples/axum-example/src/routes/foo/mod.rs +++ b/examples/axum-example/src/routes/foo/mod.rs @@ -30,8 +30,21 @@ pub struct SignupResponse { } #[vespera::route(post, path = "/foo")] -pub async fn signup(State(app_state): State>, Json(request): Json) -> Result, String> { +pub async fn signup( + State(app_state): State>, + Json(request): Json, +) -> Result, String> { println!("app_state: {:?}", app_state.config); - let response = SignupResponse { id: 1, email: request.email, name: "John Doe".to_string(), phone_number: "1234567890".to_string(), nickname: Some("John".to_string()), birthday: Some("1990-01-01".to_string()), gender: Some("male".to_string()), job: Some("engineer".to_string()), created_at: "2021-01-01".to_string() }; + let response = SignupResponse { + id: 1, + email: request.email, + name: "John Doe".to_string(), + phone_number: "1234567890".to_string(), + nickname: Some("John".to_string()), + birthday: Some("1990-01-01".to_string()), + gender: Some("male".to_string()), + job: Some("engineer".to_string()), + created_at: "2021-01-01".to_string(), + }; Ok(Json(response)) } diff --git a/examples/axum-example/src/routes/generic.rs b/examples/axum-example/src/routes/generic.rs index 3539da4..45857d0 100644 --- a/examples/axum-example/src/routes/generic.rs +++ b/examples/axum-example/src/routes/generic.rs @@ -17,21 +17,43 @@ pub struct GenericStruct2 { } #[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() }) +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() }) + 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() }) + 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() }) + 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 de8b4a4..3b6c1b9 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -86,7 +86,9 @@ pub struct StructBodyWithOptional { } #[vespera::route(post, path = "/struct-body-with-optional")] -pub async fn mod_file_with_struct_body_with_optional(Json(body): Json) -> String { +pub async fn mod_file_with_struct_body_with_optional( + Json(body): Json, +) -> String { format!("name: {:?}, age: {:?}", body.name, body.age) } @@ -107,7 +109,20 @@ pub struct ComplexStructBody { #[vespera::route(post, path = "/complex-struct-body")] pub async fn mod_file_with_complex_struct_body(Json(body): Json) -> String { - format!("name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", body.name, body.age, body.nested_struct, body.array, body.map, body.nested_array, body.nested_map, body.nested_struct_array, body.nested_struct_map, body.nested_struct_array_map, body.nested_struct_map_array) + format!( + "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", + body.name, + body.age, + body.nested_struct, + body.array, + body.map, + body.nested_array, + body.nested_map, + body.nested_struct_array, + body.nested_struct_map, + body.nested_struct_array_map, + body.nested_struct_map_array + ) } #[derive(Deserialize, Schema)] @@ -127,8 +142,23 @@ pub struct ComplexStructBodyWithRename { } #[vespera::route(post, path = "/complex-struct-body-with-rename")] -pub async fn mod_file_with_complex_struct_body_with_rename(Json(body): Json) -> String { - format!("name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", body.name, body.age, body.nested_struct, body.array, body.map, body.nested_array, body.nested_map, body.nested_struct_array, body.nested_struct_map, body.nested_struct_array_map, body.nested_struct_map_array) +pub async fn mod_file_with_complex_struct_body_with_rename( + Json(body): Json, +) -> String { + format!( + "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", + body.name, + body.age, + body.nested_struct, + body.array, + body.map, + body.nested_array, + body.nested_map, + body.nested_struct_array, + body.nested_struct_map, + body.nested_struct_array_map, + body.nested_struct_map_array + ) } #[vespera::route(get, path = "/test_struct")] diff --git a/examples/axum-example/src/routes/path/mod.rs b/examples/axum-example/src/routes/path/mod.rs index 35539ca..d8e86ad 100644 --- a/examples/axum-example/src/routes/path/mod.rs +++ b/examples/axum-example/src/routes/path/mod.rs @@ -1,14 +1,22 @@ pub mod prefix; #[vespera::route(get, path = "/multi-path/{var1}")] -pub async fn mod_file_with_test_struct(vespera::axum::extract::Path(var1): vespera::axum::extract::Path) -> &'static str { +pub async fn mod_file_with_test_struct( + vespera::axum::extract::Path(var1): vespera::axum::extract::Path, +) -> &'static str { println!("var1: {}", var1); "multi path" } // multi path #[vespera::route(get, path = "/multi-path/{arg}/{var1}/{var2}")] -pub async fn mod_file_with_multi_path(vespera::axum::extract::Path((arg2, var1, var2)): vespera::axum::extract::Path<(String, String, String)>) -> &'static str { +pub async fn mod_file_with_multi_path( + vespera::axum::extract::Path((arg2, var1, var2)): vespera::axum::extract::Path<( + String, + String, + String, + )>, +) -> &'static str { println!("arg: {}", arg2); println!("var1: {}", var1); println!("var2: {}", var2); @@ -17,7 +25,9 @@ pub async fn mod_file_with_multi_path(vespera::axum::extract::Path((arg2, var1, // multi path #[vespera::route(get, path = "/multi-path2/{arg}/{var1}/{var2}")] -pub async fn mod_file_with_multi_path_2(vespera::axum::extract::Path(path): vespera::axum::extract::Path<(String, String, String)>) -> &'static str { +pub async fn mod_file_with_multi_path_2( + vespera::axum::extract::Path(path): vespera::axum::extract::Path<(String, String, String)>, +) -> &'static str { println!("arg: {:?}", path); "multi path" } diff --git a/examples/axum-example/src/routes/typed_header.rs b/examples/axum-example/src/routes/typed_header.rs index 29ba06b..78bfa20 100644 --- a/examples/axum-example/src/routes/typed_header.rs +++ b/examples/axum-example/src/routes/typed_header.rs @@ -4,14 +4,19 @@ use vespera::axum_extra::{ }; #[vespera::route(post)] -pub async fn typed_header(TypedHeader(user_agent): TypedHeader, content_type: Option>) -> &'static str { +pub async fn typed_header( + TypedHeader(user_agent): TypedHeader, + content_type: Option>, +) -> &'static str { println!("user_agent: {:?}", user_agent); println!("content_type: {:?}", content_type); "ok" } #[vespera::route()] -pub async fn typed_header_jwt(TypedHeader(authorization): TypedHeader>) -> &'static str { +pub async fn typed_header_jwt( + TypedHeader(authorization): TypedHeader>, +) -> &'static str { println!("authorization: {:?}", authorization); "ok" } diff --git a/examples/axum-example/src/routes/users.rs b/examples/axum-example/src/routes/users.rs index ed378d8..d1bd81f 100644 --- a/examples/axum-example/src/routes/users.rs +++ b/examples/axum-example/src/routes/users.rs @@ -20,17 +20,36 @@ pub struct CreateUserRequest { /// Get all users #[vespera::route(get)] pub async fn get_users() -> Json> { - Json(vec![User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string() }, User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string() }]) + Json(vec![ + User { + id: 1, + name: "Alice".to_string(), + email: "alice@example.com".to_string(), + }, + User { + id: 2, + name: "Bob".to_string(), + email: "bob@example.com".to_string(), + }, + ]) } /// Get user by ID #[vespera::route(get, path = "/{id}")] pub async fn get_user(Path(id): Path) -> Json { - Json(User { id, name: format!("User {}", id), email: format!("user{}@example.com", id) }) + Json(User { + id, + name: format!("User {}", id), + email: format!("user{}@example.com", id), + }) } /// Create a new user #[vespera::route(post)] pub async fn create_user(Json(user): Json) -> Json { - Json(User { id: 100, name: user.name, email: user.email }) + Json(User { + id: 100, + name: user.name, + email: user.email, + }) } diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 9853800..8d5b8a1 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -191,7 +191,10 @@ async fn test_mod_file_with_complex_struct_body() { } }); - let response = server.post("/complex-struct-body").json(&complex_body).await; + let response = server + .post("/complex-struct-body") + .json(&complex_body) + .await; response.assert_status_ok(); let response_text = response.text(); @@ -277,7 +280,10 @@ async fn test_mod_file_with_complex_struct_body_with_rename() { } }); - let response = server.post("/complex-struct-body-with-rename").json(&complex_body).await; + let response = server + .post("/complex-struct-body-with-rename") + .json(&complex_body) + .await; response.assert_status_ok(); let response_text = response.text(); From a6dc7578397982775ead7743d9acc0336979c190 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 21:28:06 +0900 Subject: [PATCH 24/30] Refactor --- .../src/parser/is_keyword_type.rs | 81 +++ crates/vespera_macro/src/parser/mod.rs | 2 +- crates/vespera_macro/src/parser/response.rs | 515 +++++++----------- 3 files changed, 286 insertions(+), 312 deletions(-) create mode 100644 crates/vespera_macro/src/parser/is_keyword_type.rs diff --git a/crates/vespera_macro/src/parser/is_keyword_type.rs b/crates/vespera_macro/src/parser/is_keyword_type.rs new file mode 100644 index 0000000..cd9a239 --- /dev/null +++ b/crates/vespera_macro/src/parser/is_keyword_type.rs @@ -0,0 +1,81 @@ +use syn::{Type, TypePath}; + +#[allow(dead_code)] +pub enum KeywordType { + HeaderMap, + StatusCode, + Json, + Path, + Query, + Header, + TypedHeader, + Result, +} + +impl KeywordType { + pub fn as_str(&self) -> &str { + match self { + KeywordType::HeaderMap => "HeaderMap", + KeywordType::StatusCode => "StatusCode", + KeywordType::Json => "Json", + KeywordType::Path => "Path", + KeywordType::Query => "Query", + KeywordType::Header => "Header", + KeywordType::TypedHeader => "TypedHeader", + KeywordType::Result => "Result", + } + } +} + +pub fn is_keyword_type(ty: &Type, keyword: &KeywordType) -> bool { + if let Type::Path(type_path) = ty { + is_keyword_type_by_type_path(type_path, keyword) + } else { + false + } +} + +pub fn is_keyword_type_by_type_path(ty: &TypePath, keyword: &KeywordType) -> bool { + if let Some(segment) = ty.path.segments.last() + && segment.ident == keyword.as_str() + { + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use syn::parse_str; + + fn syn_type(ty: &str) -> Type { + parse_str::(ty).expect("Failed to parse type") + } + + #[rstest] + #[case("HeaderMap", KeywordType::HeaderMap, true)] + #[case("StatusCode", KeywordType::StatusCode, true)] + #[case("Json", KeywordType::Json, true)] + #[case("Path", KeywordType::Path, true)] + #[case("Query", KeywordType::Query, true)] + #[case("Header", KeywordType::Header, true)] + #[case("TypedHeader", KeywordType::TypedHeader, true)] + #[case("String", KeywordType::HeaderMap, false)] + #[case("HeaderMap", KeywordType::Json, false)] + #[case("axum::http::HeaderMap", KeywordType::HeaderMap, true)] + #[case("axum::http::StatusCode", KeywordType::StatusCode, true)] + #[case("othermod::Json", KeywordType::Json, true)] + #[case("CustomType", KeywordType::Path, false)] + #[case("Result", KeywordType::Result, true)] + fn test_is_keyword_type( + #[case] ty_str: &str, + #[case] keyword: KeywordType, + #[case] expected: bool, + ) { + let ty = syn_type(ty_str); + assert_eq!(is_keyword_type(&ty, &keyword), expected); + } +} diff --git a/crates/vespera_macro/src/parser/mod.rs b/crates/vespera_macro/src/parser/mod.rs index 3f27e44..52bde13 100644 --- a/crates/vespera_macro/src/parser/mod.rs +++ b/crates/vespera_macro/src/parser/mod.rs @@ -1,9 +1,9 @@ +mod is_keyword_type; mod operation; mod parameters; mod path; mod request_body; mod response; mod schema; - pub use operation::build_operation_from_function; pub use schema::{parse_enum_to_schema, parse_struct_to_schema}; diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 088327d..92ed3b1 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -3,6 +3,8 @@ use std::collections::{BTreeMap, HashMap}; use syn::{ReturnType, Type}; use vespera_core::route::{Header, MediaType, Response}; +use crate::parser::is_keyword_type::{KeywordType, is_keyword_type, is_keyword_type_by_type_path}; + use super::schema::parse_type_to_schema_ref_with_schemas; /// Unwrap Json to get T @@ -31,17 +33,14 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { let unwrapped = unwrap_json(ty); // Handle both Type::Path and Type::Reference (for &Result<...>) - let result_type = match unwrapped { - Type::Path(type_path) => type_path, - Type::Reference(type_ref) => { - // Unwrap reference and check if it's a Result - if let Type::Path(type_path) = type_ref.elem.as_ref() { - type_path - } else { - return None; - } - } - _ => return None, + let result_type = if let Type::Path(type_path) = unwrapped { + type_path + } else if let Type::Reference(type_ref) = unwrapped + && let Type::Path(type_path) = type_ref.elem.as_ref() + { + type_path + } else { + return None; }; let path = &result_type.path; @@ -49,23 +48,17 @@ fn extract_result_types(ty: &Type) -> Option<(Type, Type)> { return None; } - // Check if any segment is "Result" (handles both Result and std::result::Result) - let is_result = path.segments.iter().any(|seg| seg.ident == "Result"); - - if is_result { + if is_keyword_type_by_type_path(result_type, &KeywordType::Result) + && 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)) + { // 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())); - } + // 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 } @@ -74,38 +67,15 @@ 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 - && let Type::Path(type_path) = &tuple.elems[0] - && !&type_path.path.segments.is_empty() + && tuple + .elems + .iter() + .any(|ty| is_keyword_type(ty, &KeywordType::StatusCode)) { - let path = &type_path.path; - 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())); - } - } + Some((400, unwrap_json(tuple.elems.last().unwrap()).clone())) + } else { + None } - None -} - -fn is_header_map_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return false; - } - return path.segments.iter().any(|s| s.ident == "HeaderMap"); - } - false } /// Extract payload type from an Ok tuple and track if headers exist. @@ -114,10 +84,13 @@ fn is_header_map_type(ty: &Type) -> bool { fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { if let Type::Tuple(tuple) = ok_ty { let payload_ty = tuple.elems.last().map(|ty| unwrap_json(ty).clone()); - let has_headers = tuple.elems.iter().any(is_header_map_type); if let Some(payload_ty) = payload_ty { - let headers = if has_headers { + let headers = if tuple + .elems + .iter() + .any(|ty| is_keyword_type(ty, &KeywordType::HeaderMap)) + { Some(HashMap::new()) } else { None @@ -274,275 +247,195 @@ mod tests { use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; - #[rstest] - #[case("", "")] // No return type - #[case("-> String", "String")] // Simple return type - #[case("-> i32", "i32")] // Integer return type - #[case("-> bool", "bool")] // Boolean return type - #[case("-> Vec", "Vec")] // Array return type - #[case("-> Option", "Option")] // Option return type - #[case("-> Result", "Result")] // Result with same types - #[case("-> Result", "Result")] // Result with different types - #[case("-> Result, String>", "Result, String>")] // Result with Json wrapper - #[case( - "-> Result", - "Result" - )] // Result with status code tuple - #[case("-> &str", "&str")] // Reference return type - #[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(); + #[derive(Debug)] + struct ExpectedSchema { + schema_type: SchemaType, + nullable: bool, + items_schema_type: Option, + } - let return_type = if return_type_str.is_empty() { + #[derive(Debug)] + struct ExpectedResponse { + status: &'static str, + schema: ExpectedSchema, + } + + fn parse_return_type_str(return_type_str: &str) -> syn::ReturnType { + if return_type_str.is_empty() { syn::ReturnType::Default } else { - // Parse the return type from string let full_signature = format!("fn test() {}", return_type_str); - let parsed: syn::Signature = - syn::parse_str(&full_signature).expect("Failed to parse return type"); - parsed.output - }; - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + syn::parse_str::(&full_signature) + .expect("Failed to parse return type") + .output + } + } - match expected_type { - "" => { - // ReturnType::Default - should have 200 with no content - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_none()); - } - "String" | "&str" => { - // String return type - should have 200 with String schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema"); - } - } - "i32" => { - // Integer return type - should have 200 with Integer schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline Integer schema"); - } - } - "bool" => { - // Boolean return type - should have 200 with Boolean schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Boolean)); - } else { - panic!("Expected inline Boolean schema"); - } - } - "Vec" => { - // Array return type - should have 200 with Array schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert!(schema.items.is_some()); - // Check that items is String - if let Some(items) = &schema.items - && let SchemaRef::Inline(items_schema) = items.as_ref() - { - assert_eq!(items_schema.schema_type, Some(SchemaType::String)); + fn assert_schema_matches(schema_ref: &SchemaRef, expected: &ExpectedSchema) { + match schema_ref { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(expected.schema_type.clone())); + assert_eq!(schema.nullable.unwrap_or(false), expected.nullable); + if let Some(item_ty) = &expected.items_schema_type { + let items = schema + .items + .as_ref() + .expect("items should be present for array"); + match items.as_ref() { + SchemaRef::Inline(item_schema) => { + assert_eq!(item_schema.schema_type, Some(item_ty.clone())); + } + SchemaRef::Ref(_) => panic!("expected inline schema for array items"), } - } else { - panic!("Expected inline Array schema"); - } - } - "Option" => { - // Option return type - should have 200 with nullable String schema - assert_eq!(responses.len(), 1); - assert!(responses.contains_key("200")); - let response = responses.get("200").unwrap(); - assert_eq!(response.description, "Successful response"); - assert!(response.content.is_some()); - let content = response.content.as_ref().unwrap(); - assert!(content.contains_key("application/json")); - let media_type = content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() { - assert_eq!(schema.nullable, Some(true)); - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline nullable String schema"); } } - "Result" => { - // Result types - should have 200 for Ok and 400 for Err - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result" => { - // Result types - should have 200 for Ok and 400 for Err - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); + #[rstest] + #[case("", None, None, None)] + #[case( + "-> String", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] + #[case( + "-> &str", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] + #[case( + "-> i32", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + None, + None + )] + #[case( + "-> bool", + Some(ExpectedSchema { schema_type: SchemaType::Boolean, nullable: false, items_schema_type: None }), + None, + None + )] + #[case( + "-> Vec", + Some(ExpectedSchema { schema_type: SchemaType::Array, nullable: false, items_schema_type: Some(SchemaType::String) }), + None, + None + )] + #[case( + "-> Option", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: true, items_schema_type: None }), + None, + None + )] + #[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result, String>", + Some(ExpectedSchema { schema_type: SchemaType::Object, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result<&str, String>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result)>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] + #[case( + "-> Result<(HeaderMap, Json), String>", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + Some(true) + )] + #[case( + "-> Result)>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None } }), + None + )] + fn test_parse_return_type( + #[case] return_type_str: &str, + #[case] ok_expectation: Option, + #[case] err_expectation: Option, + #[case] ok_headers_expected: Option, + ) { + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str(return_type_str); - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline Integer schema for Ok type"); - } + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } + // Validate success response + let ok_response = responses.get("200").expect("200 response should exist"); + assert_eq!(ok_response.description, "Successful response"); + match &ok_expectation { + None => { + assert!(ok_response.content.is_none()); } - "Result, String>" => { - // Result with Json wrapper - should unwrap Json - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - // User is not in known_schemas, so it should be an object schema - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - } else { - panic!("Expected inline Object schema for User type"); - } - - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } + Some(expected_schema) => { + let content = ok_response + .content + .as_ref() + .expect("ok content should exist"); + let media_type = content + .get("application/json") + .expect("ok media type should exist"); + let schema_ref = media_type.schema.as_ref().expect("ok schema should exist"); + assert_schema_matches(schema_ref, expected_schema); } - "Result<&str, String>" => { - // Result with reference - should handle reference correctly - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - assert!(ok_response.content.is_some()); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for &str type"); - } + } + if let Some(expect_headers) = ok_headers_expected { + assert_eq!(ok_response.headers.is_some(), expect_headers); + } - let err_response = responses.get("400").unwrap(); - assert_eq!(err_response.description, "Error response"); - assert!(err_response.content.is_some()); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } - } - "Result" => { - // Result with status code tuple - should use status code from tuple + // Validate error response (if any) + match &err_expectation { + None => assert_eq!(responses.len(), 1), + Some(err) => { assert_eq!(responses.len(), 2); - assert!(responses.contains_key("200")); - assert!(responses.contains_key("400")); // Default status code from tuple - - let ok_response = responses.get("200").unwrap(); - assert_eq!(ok_response.description, "Successful response"); - let ok_content = ok_response.content.as_ref().unwrap(); - let ok_media_type = ok_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Ok type"); - } - - let err_response = responses.get("400").unwrap(); + let err_response = responses + .get(err.status) + .expect("error response should exist"); assert_eq!(err_response.description, "Error response"); - let err_content = err_response.content.as_ref().unwrap(); - let err_media_type = err_content.get("application/json").unwrap(); - if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline String schema for Err type"); - } + let content = err_response + .content + .as_ref() + .expect("error content should exist"); + let media_type = content + .get("application/json") + .expect("error media type should exist"); + let schema_ref = media_type + .schema + .as_ref() + .expect("error schema should exist"); + assert_schema_matches(schema_ref, &err.schema); } - _ => panic!("Unexpected test case"), } } } From 06c575e8327525fefe62a6190a12e49d2630f79b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 21:40:26 +0900 Subject: [PATCH 25/30] Add case --- .../src/parser/is_keyword_type.rs | 8 +--- .../vespera_macro/src/parser/request_body.rs | 38 ++++++++----------- ..._request_body_cases@req_body_self_ref.snap | 5 +++ ...equest_body_cases@req_body_vec_string.snap | 5 +++ 4 files changed, 26 insertions(+), 30 deletions(-) create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_self_ref.snap create mode 100644 crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_vec_string.snap diff --git a/crates/vespera_macro/src/parser/is_keyword_type.rs b/crates/vespera_macro/src/parser/is_keyword_type.rs index cd9a239..878bcab 100644 --- a/crates/vespera_macro/src/parser/is_keyword_type.rs +++ b/crates/vespera_macro/src/parser/is_keyword_type.rs @@ -36,13 +36,7 @@ pub fn is_keyword_type(ty: &Type, keyword: &KeywordType) -> bool { } pub fn is_keyword_type_by_type_path(ty: &TypePath, keyword: &KeywordType) -> bool { - if let Some(segment) = ty.path.segments.last() - && segment.ident == keyword.as_str() - { - true - } else { - false - } + return ty.path.segments.last().unwrap().ident == keyword.as_str(); } #[cfg(test)] diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index f411be8..03d98be 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -29,9 +29,6 @@ pub fn parse_request_body( FnArg::Typed(PatType { ty, .. }) => { if let Type::Path(type_path) = ty.as_ref() { let path = &type_path.path; - if path.segments.is_empty() { - return None; - } // Check the last segment (handles both Json and vespera::axum::Json) let segment = path.segments.last().unwrap(); @@ -93,13 +90,27 @@ mod tests { use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use std::collections::HashMap; - use vespera_core::schema::{SchemaRef, SchemaType}; + + #[rstest] + #[case("String", true)] + #[case("str", true)] + #[case("&String", true)] + #[case("&str", true)] + #[case("i32", false)] + #[case("Vec", false)] + #[case("!", false)] + fn test_is_string_like_cases(#[case] ty_src: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(ty_src).expect("type parse failed"); + assert_eq!(is_string_like(&ty), expected); + } #[rstest] #[case::json("fn test(Json(payload): Json) {}", true, "json")] #[case::string("fn test(just_string: String) {}", true, "string")] #[case::str("fn test(just_str: &str) {}", true, "str")] #[case::i32("fn test(just_i32: i32) {}", false, "i32")] + #[case::vec_string("fn test(just_vec_string: Vec) {}", false, "vec_string")] + #[case::self_ref("fn test(&self) {}", false, "self_ref")] fn test_parse_request_body_cases( #[case] func_src: &str, #[case] has_body: bool, @@ -113,23 +124,4 @@ mod tests { assert_debug_snapshot!(body); }); } - - #[test] - fn test_parse_request_body_text_plain_schema() { - let func: syn::ItemFn = syn::parse_str("fn test(body: &str) {}").unwrap(); - let arg = func.sig.inputs.first().unwrap(); - let body = parse_request_body(arg, &HashMap::new(), &HashMap::new()) - .expect("expected request body"); - - let media = body - .content - .get("text/plain") - .expect("expected text/plain content"); - - if let SchemaRef::Inline(schema) = media.schema.as_ref().expect("schema expected") { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("expected inline schema"); - } - } } diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_self_ref.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_self_ref.snap new file mode 100644 index 0000000..313c95d --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_self_ref.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +None diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_vec_string.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_vec_string.snap new file mode 100644 index 0000000..313c95d --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_vec_string.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +None From 1584a3e954db41962415e174918b579f139fad7c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 21:43:00 +0900 Subject: [PATCH 26/30] Add case --- crates/vespera_macro/src/parser/is_keyword_type.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/vespera_macro/src/parser/is_keyword_type.rs b/crates/vespera_macro/src/parser/is_keyword_type.rs index 878bcab..54b5ea7 100644 --- a/crates/vespera_macro/src/parser/is_keyword_type.rs +++ b/crates/vespera_macro/src/parser/is_keyword_type.rs @@ -64,6 +64,8 @@ mod tests { #[case("othermod::Json", KeywordType::Json, true)] #[case("CustomType", KeywordType::Path, false)] #[case("Result", KeywordType::Result, true)] + #[case("Result", KeywordType::Result, true)] + #[case("!", KeywordType::Result, false)] fn test_is_keyword_type( #[case] ty_str: &str, #[case] keyword: KeywordType, From 8b96f9e08837268069ddef7d83a25d4f385ffd42 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 22:24:19 +0900 Subject: [PATCH 27/30] Add coverage --- crates/vespera_macro/src/parser/operation.rs | 235 +++++++++++++++++-- 1 file changed, 220 insertions(+), 15 deletions(-) diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 38369ce..b8e4e1e 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -263,26 +263,231 @@ pub fn build_operation_from_function( #[cfg(test)] mod tests { use super::*; + use rstest::rstest; use std::collections::HashMap; use vespera_core::schema::{SchemaRef, SchemaType}; - #[test] - fn test_build_operation_string_body_fallback() { - let sig: syn::Signature = syn::parse_str("fn upload(data: String) -> String").unwrap(); - let op = - build_operation_from_function(&sig, "/upload", &HashMap::new(), &HashMap::new(), None); + fn param_schema_type(param: &Parameter) -> Option { + match param.schema.as_ref()? { + SchemaRef::Inline(schema) => schema.schema_type.clone(), + SchemaRef::Ref(_) => None, + } + } - // Ensure body is set as text/plain - let body = op.request_body.as_ref().expect("request body expected"); - assert!(body.content.contains_key("text/plain")); - let media = body.content.get("text/plain").unwrap(); - if let SchemaRef::Inline(schema) = media.schema.as_ref().unwrap() { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("inline string schema expected"); + fn build(sig_src: &str, path: &str, error_status: Option<&[u16]>) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function(&sig, path, &HashMap::new(), &HashMap::new(), error_status) + } + + #[derive(Clone, Debug)] + struct ExpectedParam { + name: &'static str, + schema: Option, + } + + #[derive(Clone, Debug)] + struct ExpectedBody { + content_type: &'static str, + schema: Option, + } + + #[derive(Clone, Debug)] + struct ExpectedResp { + status: &'static str, + schema: Option, + } + + fn assert_body(op: &Operation, expected: &Option) { + match expected { + None => assert!(op.request_body.is_none()), + Some(exp) => { + let body = op.request_body.as_ref().expect("request body expected"); + let media = body.content.get(exp.content_type).or_else(|| { + // allow fallback to the only available content type if expected is absent + if body.content.len() == 1 { + body.content.values().next() + } else { + None + } + }).expect("expected content type"); + if let Some(schema_ty) = &exp.schema { + match media.schema.as_ref().expect("schema expected") { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(schema_ty.clone())); + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } + } + } + } + + fn assert_params(op: &Operation, expected: &[ExpectedParam]) { + match op.parameters.as_ref() { + None => assert!(expected.is_empty()), + Some(params) => { + assert_eq!(params.len(), expected.len()); + for (param, exp) in params.iter().zip(expected) { + assert_eq!(param.name, exp.name); + assert_eq!(param_schema_type(param), exp.schema); + } + } } + } + + fn assert_responses(op: &Operation, expected: &[ExpectedResp]) { + for exp in expected { + let resp = op.responses.get(exp.status).expect("response missing"); + let media = resp + .content + .as_ref() + .and_then(|c| c.get("application/json")) + .or_else(|| resp.content.as_ref().and_then(|c| c.get("text/plain"))) + .expect("media type missing"); + if let Some(schema_ty) = &exp.schema { + match media.schema.as_ref().expect("schema expected") { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(schema_ty.clone())); + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } + } + } - // No parameters should be present - assert!(op.parameters.is_none()); + #[rstest] + #[case( + "fn upload(data: String) -> String", + "/upload", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn upload_ref(data: &str) -> String", + "/upload", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn get(Path(params): Path<(i32,)>) -> String", + "/users/{id}/{name}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, + ExpectedParam { name: "name", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn get() -> String", + "/items/{item_id}", + None::<&[u16]>, + vec![ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn get(Path(id): Path) -> String", + "/shops/{shop_id}/items/{item_id}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "shop_id", schema: Some(SchemaType::String) }, + ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn create(Json(body): Json) -> Result", + "/create", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "application/json", schema: None }), + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "400", schema: Some(SchemaType::String) }, + ] + )] + #[case( + "fn get(Path(params): Path<(i32,)>) -> String", + "/users/{id}/{name}/{extra}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, + ExpectedParam { name: "name", schema: Some(SchemaType::String) }, + ExpectedParam { name: "extra", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn get() -> String", + "/items/{item_id}/extra/{more}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, + ExpectedParam { name: "more", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn post(data: String) -> String", + "/post", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn no_error_extra() -> String", + "/plain", + Some(&[500u16][..]), + vec![], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] + #[case( + "fn create() -> Result", + "/create", + Some(&[400u16, 500u16][..]), + vec![], + None, + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "400", schema: Some(SchemaType::String) }, + ExpectedResp { status: "500", schema: Some(SchemaType::String) }, + ] + )] + #[case( + "fn create() -> Result", + "/create", + Some(&[401u16, 402u16][..]), + vec![], + None, + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "400", schema: Some(SchemaType::String) }, + ExpectedResp { status: "401", schema: Some(SchemaType::String) }, + ExpectedResp { status: "402", schema: Some(SchemaType::String) }, + ] + )] + fn test_build_operation_cases( + #[case] sig_src: &str, + #[case] path: &str, + #[case] extra_status: Option<&[u16]>, + #[case] expected_params: Vec, + #[case] expected_body: Option, + #[case] expected_resps: Vec, + ) { + let op = build(sig_src, path, extra_status); + assert_params(&op, &expected_params); + assert_body(&op, &expected_body); + assert_responses(&op, &expected_resps); } } From a3597026d5e8a6bd96742157420bdeb348802641 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 22:33:20 +0900 Subject: [PATCH 28/30] Create dir --- crates/vespera_macro/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index e11f644..97f5e46 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -248,7 +248,13 @@ pub fn vespera(input: TokenStream) -> TokenStream { } }; for openapi_file_name in &openapi_file_names { - if let Err(e) = std::fs::write(openapi_file_name, &json_str) { + // create directory if not exists + let file_path = Path::new(openapi_file_name); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + + if let Err(e) = std::fs::write(file_path, &json_str) { return syn::Error::new( Span::call_site(), format!( From d84ed7fee2fa67f6ccae556466e97460a57886a4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 22:44:47 +0900 Subject: [PATCH 29/30] Add note --- .changepacks/changepack_log_POJdvdKMVhGW0R4GlSTtr.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_POJdvdKMVhGW0R4GlSTtr.json diff --git a/.changepacks/changepack_log_POJdvdKMVhGW0R4GlSTtr.json b/.changepacks/changepack_log_POJdvdKMVhGW0R4GlSTtr.json new file mode 100644 index 0000000..91e8ccd --- /dev/null +++ b/.changepacks/changepack_log_POJdvdKMVhGW0R4GlSTtr.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch"},"note":"Refactor, Mkdir for openapi","date":"2025-12-11T13:44:43.388156300Z"} \ No newline at end of file From b5911691b27e7d3fcec7e3d31675cb53237d8d30 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 22:49:22 +0900 Subject: [PATCH 30/30] Fix lint --- crates/vespera_macro/src/parser/is_keyword_type.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vespera_macro/src/parser/is_keyword_type.rs b/crates/vespera_macro/src/parser/is_keyword_type.rs index 54b5ea7..9ecd7fd 100644 --- a/crates/vespera_macro/src/parser/is_keyword_type.rs +++ b/crates/vespera_macro/src/parser/is_keyword_type.rs @@ -36,7 +36,7 @@ pub fn is_keyword_type(ty: &Type, keyword: &KeywordType) -> bool { } pub fn is_keyword_type_by_type_path(ty: &TypePath, keyword: &KeywordType) -> bool { - return ty.path.segments.last().unwrap().ident == keyword.as_str(); + ty.path.segments.last().unwrap().ident == keyword.as_str() } #[cfg(test)]