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 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fad29bb..081bae0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,8 +35,21 @@ jobs: components: clippy, rustfmt - name: Build run: cargo check + - name: Lint + run: cargo clippy --all-targets --all-features -- -D warnings - 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 Stdout - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/Cargo.lock b/Cargo.lock index 7c729cb..788a722 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", @@ -389,6 +473,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 +507,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 +757,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 +783,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 +793,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 +820,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 +853,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" @@ -1130,6 +1265,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 +1314,17 @@ dependencies = [ "serde", ] +[[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 +1368,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 +1600,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" @@ -1501,16 +1672,17 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.12" +version = "0.1.13" dependencies = [ "axum", + "axum-extra", "vespera_core", "vespera_macro", ] [[package]] name = "vespera_core" -version = "0.1.12" +version = "0.1.13" dependencies = [ "rstest", "serde", @@ -1519,9 +1691,10 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", + "insta", "proc-macro2", "quote", "rstest", @@ -1558,9 +1731,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 +1744,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 +1754,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 +1767,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_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 47ba6cf..d1b9330 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 { @@ -387,3 +371,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/Cargo.toml b/crates/vespera_macro/Cargo.toml index 0a9123a..9b180ed 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -21,4 +21,5 @@ anyhow = "1.0" [dev-dependencies] rstest = "0.26" -tempfile = "3" \ No newline at end of file +insta = "1.44" +tempfile = "3" diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index b9b58cb..418a77b 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()); } } @@ -181,10 +181,9 @@ mod tests { lit: syn::Lit::Int(lit_int), .. }) = elem + && let Ok(code) = lit_int.base10_parse::() { - if let Ok(code) = lit_int.base10_parse::() { - status_codes.push(code); - } + 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..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!( @@ -435,7 +441,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 +598,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 +683,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 +737,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 +783,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 +819,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 84fed24..b371216 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"); @@ -407,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.rs b/crates/vespera_macro/src/parser.rs deleted file mode 100644 index d498d79..0000000 --- a/crates/vespera_macro/src/parser.rs +++ /dev/null @@ -1,2295 +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 { - match &tuple_struct.elems[0] { - Pat::Ident(ident) => ident.ident.to_string(), - _ => return None, - } - } 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, - }]); - } - - // 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, - }]); - } - - 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, // prefixItems와 items는 함께 사용하지 않음 - ..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 - { - // Check if first element is StatusCode - if let Type::Path(type_path) = &tuple.elems[0] { - let path = &type_path.path; - if !path.segments.is_empty() { - let segment = &path.segments[0]; - // Check if it's StatusCode (could be qualified like axum::http::StatusCode) - let is_status_code = segment.ident == "StatusCode" - || (path.segments.len() > 1 - && path.segments.iter().any(|s| s.ident == "StatusCode")); - - if is_status_code { - // Use 400 as default status code - // The actual status code value is determined at runtime - if let Some(error_type) = tuple.elems.get(1) { - // Unwrap Json if present - let error_type_unwrapped = unwrap_json(error_type); - return Some((400, error_type_unwrapped.clone())); - } - } - } - } - } - None -} - -/// 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 { - 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 - } - } else { - false - }; - - if !is_path_extractor { - // Process non-Path parameters - if let Some(params) = - parse_function_parameter(input, &path_params, known_schemas, struct_definitions) - { - parameters.extend(params); - } - } - } - } - - // 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"), - } - } - - #[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() { - 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); - - 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_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") - .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)); - } else { - panic!( - "Expected inline schema for primitive type: {}", - return_type_str - ); - } - } - } - - #[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)); - } - } - } else { - panic!("Expected inline nullable schema"); - } - } - - #[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/is_keyword_type.rs b/crates/vespera_macro/src/parser/is_keyword_type.rs new file mode 100644 index 0000000..9ecd7fd --- /dev/null +++ b/crates/vespera_macro/src/parser/is_keyword_type.rs @@ -0,0 +1,77 @@ +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 { + ty.path.segments.last().unwrap().ident == keyword.as_str() +} + +#[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)] + #[case("Result", KeywordType::Result, true)] + #[case("!", KeywordType::Result, false)] + 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 new file mode 100644 index 0000000..52bde13 --- /dev/null +++ b/crates/vespera_macro/src/parser/mod.rs @@ -0,0 +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/operation.rs b/crates/vespera_macro/src/parser/operation.rs new file mode 100644 index 0000000..b8e4e1e --- /dev/null +++ b/crates/vespera_macro/src/parser/operation.rs @@ -0,0 +1,493 @@ +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() + && 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::{SchemaRef, SchemaType}; + + fn param_schema_type(param: &Parameter) -> Option { + match param.schema.as_ref()? { + SchemaRef::Inline(schema) => schema.schema_type.clone(), + SchemaRef::Ref(_) => None, + } + } + + 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"), + } + } + } + } + + #[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); + } +} diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs new file mode 100644 index 0000000..9e43919 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -0,0 +1,845 @@ +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" + && 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 + // 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, + 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); + } + + // 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) { + 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() + { + // 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, + }]); + } + } + "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_primitive_like(ty: &Type) -> bool { + if is_primitive_type(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 +} + +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 insta::{assert_debug_snapshot, with_settings}; + use rstest::rstest; + use std::collections::HashMap; + use vespera_core::route::ParameterLocation; + + 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(), + 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)>) {}", + vec!["user_id".to_string(), "count".to_string()], + vec![vec![ParameterLocation::Path, ParameterLocation::Path]], + "path_tuple" + )] + #[case( + "fn show(Path(id): Path) {}", + vec!["item_id".to_string()], + vec![vec![ParameterLocation::Path]], + "path_single" + )] + #[case( + "fn test(Query(params): Query>) {}", + vec![], + vec![vec![]], + "query_hashmap" + )] + #[case( + "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", + vec![], + vec![ + vec![ParameterLocation::Header], + vec![], + ], + "typed_header_and_arg" + )] + #[case( + "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", + vec![], + vec![ + vec![ParameterLocation::Header], + vec![ParameterLocation::Header], + vec![ParameterLocation::Header], + ], + "typed_header_multi" + )] + #[case( + "fn test(user_agent: TypedHeader, count: i32) {}", + vec![], + vec![ + vec![ParameterLocation::Header], + vec![], + ], + "header_value_and_arg" + )] + #[case( + "fn test(&self, id: i32) {}", + vec![], + vec![ + vec![], + vec![], + ], + "method_receiver" + )] + #[case( + "fn test(Path((a, b)): Path<(i32, String)>) {}", + vec![], + vec![vec![]], + "path_tuple_destructure" + )] + #[case( + "fn test(params: Query) {}", + vec![], + vec![vec![ParameterLocation::Query, ParameterLocation::Query]], + "query_struct" + )] + #[case( + "fn test(body: Json) {}", + vec![], + vec![vec![]], + "json_body" + )] + #[case( + "fn test(params: Query) {}", + vec![], + vec![vec![]], + "query_unknown" + )] + #[case( + "fn test(params: Query>) {}", + vec![], + vec![vec![]], + "query_map" + )] + #[case( + "fn test(user: Query) {}", + vec![], + vec![vec![ParameterLocation::Query, ParameterLocation::Query]], + "query_user" + )] + #[case( + "fn test(custom: Header) {}", + vec![], + 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, + ) { + 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()); + + 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}" + ); + parameters.extend(params.clone()); + } + with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { + 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()], + )] + #[case( + "fn test((x, y): (i32, i32)) {}", + 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(), + ); + 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, &path_params, &known_schemas, &struct_definitions); + assert!( + result.is_none(), + "Expected None at arg index {}, func: {}, got: {:?}", + idx, + func_src, + result + ); + } + } + + #[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] + #[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] + 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/path.rs b/crates/vespera_macro/src/parser/path.rs new file mode 100644 index 0000000..f4f4ec1 --- /dev/null +++ b/crates/vespera_macro/src/parser/path.rs @@ -0,0 +1,32 @@ +/// 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..03d98be --- /dev/null +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -0,0 +1,127 @@ +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; + + // 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 insta::{assert_debug_snapshot, with_settings}; + use rstest::rstest; + use std::collections::HashMap; + + #[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, + #[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); + with_settings!({ snapshot_suffix => format!("req_body_{}", suffix) }, { + assert_debug_snapshot!(body); + }); + } +} diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs new file mode 100644 index 0000000..92ed3b1 --- /dev/null +++ b/crates/vespera_macro/src/parser/response.rs @@ -0,0 +1,441 @@ +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 +/// 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 = 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; + if path.segments.is_empty() { + return None; + } + + 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 + // 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 + .iter() + .any(|ty| is_keyword_type(ty, &KeywordType::StatusCode)) + { + Some((400, unwrap_json(tuple.elems.last().unwrap()).clone())) + } else { + None + } +} + +/// 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()); + + if let Some(payload_ty) = payload_ty { + let headers = if tuple + .elems + .iter() + .any(|ty| is_keyword_type(ty, &KeywordType::HeaderMap)) + { + 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}; + + #[derive(Debug)] + struct ExpectedSchema { + schema_type: SchemaType, + nullable: bool, + items_schema_type: Option, + } + + #[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 { + let full_signature = format!("fn test() {}", return_type_str); + syn::parse_str::(&full_signature) + .expect("Failed to parse return type") + .output + } + } + + 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"), + } + } + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } + + #[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 responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + // 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()); + } + 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); + } + } + if let Some(expect_headers) = ok_headers_expected { + assert_eq!(ok_response.headers.is_some(), expect_headers); + } + + // Validate error response (if any) + match &err_expectation { + None => assert_eq!(responses.len(), 1), + Some(err) => { + assert_eq!(responses.len(), 2); + let err_response = responses + .get(err.status) + .expect("error response should exist"); + assert_eq!(err_response.description, "Error response"); + 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); + } + } + } +} diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs new file mode 100644 index 0000000..1746c59 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema.rs @@ -0,0 +1,1312 @@ +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") { + // 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(); + // 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 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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 insta::{assert_debug_snapshot, with_settings}; + 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); + } + } + + #[rstest] + #[case( + r#" + #[serde(rename_all = "kebab-case")] + enum Status { + #[serde(rename = "ok-status")] + Ok, + ErrorCode, + } + "#, + SchemaType::String, + vec!["ok-status", "error-code"], // rename_all is not applied in this path + "status" + )] + #[case( + r#" + enum Simple { + First, + Second, + } + "#, + SchemaType::String, + vec!["First", "Second"], + "simple" + )] + #[case( + r#" + #[serde(rename_all = "snake_case")] + enum Simple { + FirstItem, + SecondItem, + } + "#, + SchemaType::String, + 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()); + 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); + with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { + 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 + "tuple_single" + )] + #[case( + r#" + enum Pair { + Values(i32, String), + } + "#, + 1, + Some(SchemaType::Array), + 2, // tuple array prefix_items length + "tuple_multi" + )] + #[case( + r#" + enum Msg { + Detail { id: i32, note: Option }, + } + "#, + 1, + Some(SchemaType::Object), + 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.clone() { + 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"); + } + } + + 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] + 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( + 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_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] + #[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 + 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 + 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] + #[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")] + #[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@params_header_custom.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_custom.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@params_header_custom.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, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap new file mode 100644 index 0000000..4785bb1 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_header_value_and_arg.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +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@params_json_body.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_json_body.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@params_json_body.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@params_method_receiver.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_method_receiver.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@params_method_receiver.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@params_path_single.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.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@params_path_single.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@params_path_tuple.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap new file mode 100644 index 0000000..93ae0c3 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap @@ -0,0 +1,116 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +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, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple_destructure.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple_destructure.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@params_path_tuple_destructure.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@params_query_hashmap.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_hashmap.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@params_query_hashmap.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@params_query_map.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_map.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@params_query_map.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@params_query_struct.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.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@params_query_struct.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@params_query_unknown.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_unknown.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@params_query_unknown.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@params_query_user.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap new file mode 100644 index 0000000..b48f614 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap @@ -0,0 +1,116 @@ +--- +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@params_typed_header_and_arg.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_and_arg.snap new file mode 100644 index 0000000..4785bb1 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_and_arg.snap @@ -0,0 +1,61 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +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@params_typed_header_multi.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_multi.snap new file mode 100644 index 0000000..22e5ba6 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_typed_header_multi.snap @@ -0,0 +1,171 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +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__request_body__tests__parse_request_body_cases@req_body_i32.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_i32.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_i32.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_json.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_json.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@req_body_json.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/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_str.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_str.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@req_body_str.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@req_body_string.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_string.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@req_body_string.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@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 diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.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 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@tuple_named_named_object.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@tuple_named_tuple_multi.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 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@tuple_named_tuple_multi.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@tuple_named_tuple_single.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 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@tuple_named_tuple_single.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@unit_simple.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.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@unit_simple.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@unit_simple_snake.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.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@unit_simple_snake.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, +} 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 new file mode 100644 index 0000000..e42df38 --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.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("error-code"), + ], + ), + 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/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index fd4449a..75f8239 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -39,16 +39,17 @@ 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 - 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), .. @@ -80,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, + }); } } } @@ -117,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); @@ -221,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>)>, 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..3b6c1b9 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -14,6 +14,7 @@ pub mod foo; pub mod generic; pub mod health; pub mod path; +pub mod typed_header; pub mod users; /// Health check endpoint 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..78bfa20 --- /dev/null +++ b/examples/axum-example/src/routes/typed_header.rs @@ -0,0 +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 { + 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",