From 19478e35667bffa402ac49c0d82762a004afd4bb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 31 Dec 2025 17:12:43 +0900 Subject: [PATCH 1/2] Impl description, async issue --- .../changepack_log_z-BQF0lAvHD8CkNVDl4dG.json | 1 + .claude/settings.local.json | 7 ++ README.md | 67 +++++++++++++++++++ SKILL.md | 36 ++++++++++ crates/vespera_macro/src/args.rs | 8 +++ crates/vespera_macro/src/collector.rs | 9 ++- crates/vespera_macro/src/lib.rs | 10 +++ crates/vespera_macro/src/metadata.rs | 3 + crates/vespera_macro/src/openapi_generator.rs | 14 +++- crates/vespera_macro/src/route/utils.rs | 36 ++++++++++ examples/axum-example/openapi.json | 5 ++ examples/axum-example/src/routes/mod.rs | 2 + .../snapshots/integration_test__openapi.snap | 5 ++ openapi.json | 5 ++ 14 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .changepacks/changepack_log_z-BQF0lAvHD8CkNVDl4dG.json create mode 100644 .claude/settings.local.json diff --git a/.changepacks/changepack_log_z-BQF0lAvHD8CkNVDl4dG.json b/.changepacks/changepack_log_z-BQF0lAvHD8CkNVDl4dG.json new file mode 100644 index 0000000..af75379 --- /dev/null +++ b/.changepacks/changepack_log_z-BQF0lAvHD8CkNVDl4dG.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch"},"note":"Implement description, async error","date":"2025-12-31T07:14:07.530788400Z"} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cb8a1f0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo test:*)" + ] + } +} diff --git a/README.md b/README.md index 8286c16..d314615 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,73 @@ pub async fn get_user(id: u32) -> Json { } ``` +### Tags and Description + +You can add tags and descriptions to your routes for better OpenAPI documentation organization. + +#### Tags + +Use the `tags` parameter to group your routes in the OpenAPI documentation: + +```rust +#[vespera::route(get, tags = ["users"])] +pub async fn list_users() -> Json> { + // ... +} + +#[vespera::route(post, tags = ["users", "admin"])] +pub async fn create_user(Json(user): Json) -> Json { + // ... +} +``` + +#### Description + +There are two ways to add descriptions to your routes: + +**1. Using doc comments (recommended):** + +Doc comments (`///`) are automatically extracted and used as the route description in OpenAPI: + +```rust +/// Get all users from the database +/// +/// Returns a list of all registered users. +#[vespera::route(get)] +pub async fn list_users() -> Json> { + // ... +} +``` + +**2. Using the `description` parameter:** + +You can also explicitly set the description using the `description` parameter. This takes priority over doc comments: + +```rust +/// This doc comment will be ignored +#[vespera::route(get, description = "Custom description for OpenAPI")] +pub async fn list_users() -> Json> { + // ... +} +``` + +#### Combined Example + +```rust +/// Get user by ID +/// +/// Retrieves a specific user by their unique identifier. +#[vespera::route(get, path = "/{id}", tags = ["users"])] +pub async fn get_user(Path(id): Path) -> Json { + // ... +} + +#[vespera::route(post, tags = ["users", "admin"], description = "Create a new user account")] +pub async fn create_user(Json(user): Json) -> Json { + // ... +} +``` + ### Supported HTTP Methods - `GET` diff --git a/SKILL.md b/SKILL.md index efe6d91..8595229 100644 --- a/SKILL.md +++ b/SKILL.md @@ -67,6 +67,42 @@ pub async fn create_user(Json(user): Json) -> Json { } ``` +### 4. Tags and Description + +Add tags to group routes and descriptions for OpenAPI documentation. + +**Tags:** Use the `tags` parameter to group routes. + +```rust +#[vespera::route(get, tags = ["users"])] +pub async fn list_users() -> Json> { ... } + +#[vespera::route(post, tags = ["users", "admin"])] +pub async fn create_user(Json(user): Json) -> Json { ... } +``` + +**Description:** Two ways to add descriptions: + +1. **Doc comments (recommended):** Automatically extracted from `///` comments. +```rust +/// Get all users from the database +#[vespera::route(get)] +pub async fn list_users() -> Json> { ... } +``` + +2. **Explicit `description` parameter:** Takes priority over doc comments. +```rust +#[vespera::route(get, description = "Custom description")] +pub async fn list_users() -> Json> { ... } +``` + +**Combined example:** +```rust +/// Get user by ID +#[vespera::route(get, path = "/{id}", tags = ["users"])] +pub async fn get_user(Path(id): Path) -> Json { ... } +``` + ### 5. Error Handling Vespera supports `Result` return types. It automatically documents both the success capability (200 OK) and the error responses in the OpenAPI spec. diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index 3e1abfd..d414808 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -3,6 +3,7 @@ pub struct RouteArgs { pub path: Option, pub error_status: Option, pub tags: Option, + pub description: Option, } impl syn::parse::Parse for RouteArgs { @@ -11,6 +12,7 @@ impl syn::parse::Parse for RouteArgs { let mut path: Option = None; let mut error_status: Option = None; let mut tags: Option = None; + let mut description: Option = None; // Parse comma-separated list of arguments while !input.is_empty() { @@ -39,6 +41,11 @@ impl syn::parse::Parse for RouteArgs { let array: syn::ExprArray = input.parse()?; tags = Some(array); } + "description" => { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + description = Some(lit); + } _ => { return Err(lookahead.error()); } @@ -60,6 +67,7 @@ impl syn::parse::Parse for RouteArgs { path, error_status, tags, + description, }) } } diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 1b5811f..37a6ac5 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -2,7 +2,7 @@ use crate::file_utils::{collect_files, file_to_segments}; use crate::metadata::{CollectedMetadata, RouteMetadata}; -use crate::route::extract_route_info; +use crate::route::{extract_doc_comment, extract_route_info}; use anyhow::{Context, Result}; use std::path::Path; use syn::Item; @@ -61,6 +61,12 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result doc comment + let description = route_info + .description + .clone() + .or_else(|| extract_doc_comment(&fn_item.attrs)); + metadata.routes.push(RouteMetadata { method: route_info.method, path: route_path, @@ -70,6 +76,7 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result TokenStream { .into(); } + // Validate function is async + if item_fn.sig.asyncness.is_none() { + return syn::Error::new_spanned( + item_fn.sig.fn_token, + "route function must be async", + ) + .to_compile_error() + .into(); + } + item } diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index ba8f2b4..049aa48 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -23,6 +23,9 @@ pub struct RouteMetadata { /// Tags for OpenAPI grouping #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// Description for OpenAPI (from route attribute or doc comment) + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Struct metadata diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index ad40c6d..eac92e4 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -139,7 +139,7 @@ pub fn generate_openapi_doc_with_metadata( } // Build operation from function signature - let operation = build_operation_from_function( + let mut operation = build_operation_from_function( &fn_item.sig, &route_meta.path, &known_schema_names, @@ -148,6 +148,11 @@ pub fn generate_openapi_doc_with_metadata( route_meta.tags.as_deref(), ); + // Set description from metadata + if let Some(desc) = &route_meta.description { + operation.description = Some(desc.clone()); + } + // Get or create PathItem let path_item = paths .entry(route_meta.path.clone()) @@ -502,6 +507,7 @@ pub fn get_users() -> String { signature: "fn get_users() -> String".to_string(), error_status: None, tags: None, + description: None, }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -584,6 +590,7 @@ pub fn get_status() -> Status { signature: "fn get_status() -> Status".to_string(), error_status: None, tags: None, + description: None, }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -642,6 +649,7 @@ pub fn get_user() -> User { signature: "fn get_user() -> User".to_string(), error_status: None, tags: None, + description: None, }); let doc = generate_openapi_doc_with_metadata( @@ -689,6 +697,7 @@ pub fn create_user() -> String { signature: "fn get_users() -> String".to_string(), error_status: None, tags: None, + description: None, }); metadata.routes.push(RouteMetadata { method: "POST".to_string(), @@ -699,6 +708,7 @@ pub fn create_user() -> String { signature: "fn create_user() -> String".to_string(), error_status: None, tags: None, + description: None, }); let doc = generate_openapi_doc_with_metadata(None, None, &metadata); @@ -722,6 +732,7 @@ pub fn create_user() -> String { signature: "fn get_users() -> String".to_string(), error_status: None, tags: None, + description: None, }), false, // struct should not be added false, // route should not be added @@ -737,6 +748,7 @@ pub fn create_user() -> String { signature: "fn get_users() -> String".to_string(), error_status: None, tags: None, + description: None, }), false, // struct should not be added false, // route should not be added diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index a1203fc..4db2b83 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -1,11 +1,41 @@ use crate::args::RouteArgs; +/// Extract doc comments from attributes +/// Returns concatenated doc comment string or None if no doc comments +pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") { + if let syn::Meta::NameValue(meta_nv) = &attr.meta { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let line = lit_str.value(); + // Trim leading space that rustdoc adds + let trimmed = line.strip_prefix(' ').unwrap_or(&line); + doc_lines.push(trimmed.to_string()); + } + } + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + #[derive(Debug)] pub struct RouteInfo { pub method: String, pub path: Option, pub error_status: Option>, pub tags: Option>, + pub description: Option, } pub fn check_route_by_meta(meta: &syn::Meta) -> bool { @@ -86,11 +116,15 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { } }); + // Parse description if present + let description = route_args.description.as_ref().map(|s| s.value()); + return Some(RouteInfo { method, path, error_status, tags, + description, }); } } @@ -115,6 +149,7 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { path: None, error_status: None, tags: None, + description: None, }); } } @@ -126,6 +161,7 @@ pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { path: None, error_status: None, tags: None, + description: None, }); } } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index ac0c674..e0e4fa8 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -13,6 +13,7 @@ "/": { "get": { "operationId": "root_endpoint", + "description": "Health check endpoint", "responses": { "200": { "description": "Successful response", @@ -493,6 +494,7 @@ "tags": [ "hello" ], + "description": "Hello!!", "responses": { "200": { "description": "Successful response", @@ -895,6 +897,7 @@ "/users": { "get": { "operationId": "get_users", + "description": "Get all users", "responses": { "200": { "description": "Successful response", @@ -913,6 +916,7 @@ }, "post": { "operationId": "create_user", + "description": "Create a new user", "requestBody": { "required": true, "content": { @@ -957,6 +961,7 @@ "/users/{id}": { "get": { "operationId": "get_user", + "description": "Get user by ID", "parameters": [ { "name": "id", diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index c4b5a37..a8a30d2 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -23,6 +23,8 @@ pub async fn root_endpoint() -> &'static str { "root endpoint" } + +/// Hello!! #[vespera::route(get, path = "/hello", tags = ["hello"])] pub async fn mod_file_endpoint() -> &'static str { "mod file endpoint" diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 6543aa2..045be91 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -17,6 +17,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "/": { "get": { "operationId": "root_endpoint", + "description": "Health check endpoint", "responses": { "200": { "description": "Successful response", @@ -497,6 +498,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "tags": [ "hello" ], + "description": "Hello!!", "responses": { "200": { "description": "Successful response", @@ -899,6 +901,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "/users": { "get": { "operationId": "get_users", + "description": "Get all users", "responses": { "200": { "description": "Successful response", @@ -917,6 +920,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "post": { "operationId": "create_user", + "description": "Create a new user", "requestBody": { "required": true, "content": { @@ -961,6 +965,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "/users/{id}": { "get": { "operationId": "get_user", + "description": "Get user by ID", "parameters": [ { "name": "id", diff --git a/openapi.json b/openapi.json index ac0c674..e0e4fa8 100644 --- a/openapi.json +++ b/openapi.json @@ -13,6 +13,7 @@ "/": { "get": { "operationId": "root_endpoint", + "description": "Health check endpoint", "responses": { "200": { "description": "Successful response", @@ -493,6 +494,7 @@ "tags": [ "hello" ], + "description": "Hello!!", "responses": { "200": { "description": "Successful response", @@ -895,6 +897,7 @@ "/users": { "get": { "operationId": "get_users", + "description": "Get all users", "responses": { "200": { "description": "Successful response", @@ -913,6 +916,7 @@ }, "post": { "operationId": "create_user", + "description": "Create a new user", "requestBody": { "required": true, "content": { @@ -957,6 +961,7 @@ "/users/{id}": { "get": { "operationId": "get_user", + "description": "Get user by ID", "parameters": [ { "name": "id", From 6434d3d3fe3d360cca0e8b60acd5037913db4f57 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 31 Dec 2025 17:52:32 +0900 Subject: [PATCH 2/2] Fix lint --- crates/vespera_macro/src/lib.rs | 1739 +++++++++++------------ crates/vespera_macro/src/route/utils.rs | 8 +- examples/axum-example/src/routes/mod.rs | 337 +++-- 3 files changed, 1039 insertions(+), 1045 deletions(-) diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 619d829..324c7bd 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -1,871 +1,868 @@ -mod args; -mod collector; -mod file_utils; -mod metadata; -mod method; -mod openapi_generator; -mod parser; -mod route; - -use proc_macro::TokenStream; -use proc_macro2::Span; -use quote::quote; -use std::path::Path; -use std::sync::{LazyLock, Mutex}; -use syn::LitStr; -use syn::bracketed; -use syn::parse::{Parse, ParseStream}; -use syn::punctuated::Punctuated; - -use crate::collector::collect_metadata; -use crate::metadata::{CollectedMetadata, StructMetadata}; -use crate::method::http_method_to_token_stream; -use crate::openapi_generator::generate_openapi_doc_with_metadata; -use vespera_core::route::HttpMethod; - -/// route attribute macro -#[proc_macro_attribute] -pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { - // Validate attribute arguments - if let Err(e) = syn::parse::(attr) { - return e.to_compile_error().into(); - } - - // Validate that item is a function - let item_fn = match syn::parse::(item.clone()) { - Ok(f) => f, - Err(e) => { - return syn::Error::new(e.span(), "route attribute can only be applied to functions") - .to_compile_error() - .into(); - } - }; - - // Validate function is pub - if !matches!(item_fn.vis, syn::Visibility::Public(_)) { - return syn::Error::new_spanned(item_fn.sig.fn_token, "route function must be public") - .to_compile_error() - .into(); - } - - // Validate function is async - if item_fn.sig.asyncness.is_none() { - return syn::Error::new_spanned( - item_fn.sig.fn_token, - "route function must be async", - ) - .to_compile_error() - .into(); - } - - item -} - -// Schema Storage global variable -static SCHEMA_STORAGE: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); - -/// Derive macro for Schema -#[proc_macro_derive(Schema)] -pub fn derive_schema(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as syn::DeriveInput); - let name = &input.ident; - let generics = &input.generics; - - let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); - schema_storage.push(StructMetadata { - name: name.to_string(), - definition: quote::quote!(#input).to_string(), - }); - - // Mark both struct and enum as having SchemaBuilder - // For generic types, include the generic parameters in the impl - // The actual schema generation will be done at runtime - let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let expanded = quote! { - impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {} - }; - - TokenStream::from(expanded) -} - -struct AutoRouterInput { - dir: Option, - openapi: Option>, - title: Option, - version: Option, - docs_url: Option, - redoc_url: Option, -} - -impl Parse for AutoRouterInput { - fn parse(input: ParseStream) -> syn::Result { - let mut dir = None; - let mut openapi = None; - let mut title = None; - let mut version = None; - let mut docs_url = None; - let mut redoc_url = None; - - while !input.is_empty() { - let lookahead = input.lookahead1(); - - if lookahead.peek(syn::Ident) { - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "dir" => { - input.parse::()?; - dir = Some(input.parse()?); - } - "openapi" => { - openapi = Some(parse_openapi_values(input)?); - } - "docs_url" => { - input.parse::()?; - docs_url = Some(input.parse()?); - } - "redoc_url" => { - input.parse::()?; - redoc_url = Some(input.parse()?); - } - "title" => { - input.parse::()?; - title = Some(input.parse()?); - } - "version" => { - input.parse::()?; - version = Some(input.parse()?); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!( - "unknown field: `{}`. Expected `dir` or `openapi`", - ident_str - ), - )); - } - } - } else if lookahead.peek(syn::LitStr) { - // If just a string, treat it as dir (for backward compatibility) - dir = Some(input.parse()?); - } else { - return Err(lookahead.error()); - } - - if input.peek(syn::Token![,]) { - input.parse::()?; - } else { - break; - } - } - - Ok(AutoRouterInput { - dir: dir.or_else(|| { - std::env::var("VESPERA_DIR") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - openapi: openapi.or_else(|| { - std::env::var("VESPERA_OPENAPI") - .map(|f| vec![LitStr::new(&f, Span::call_site())]) - .ok() - }), - title: title.or_else(|| { - std::env::var("VESPERA_TITLE") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - version: version.or_else(|| { - std::env::var("VESPERA_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - docs_url: docs_url.or_else(|| { - std::env::var("VESPERA_DOCS_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - redoc_url: redoc_url.or_else(|| { - std::env::var("VESPERA_REDOC_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - }) - } -} - -fn parse_openapi_values(input: ParseStream) -> syn::Result> { - input.parse::()?; - - if input.peek(syn::token::Bracket) { - let content; - let _ = bracketed!(content in input); - let entries: Punctuated = - content.parse_terminated(|input| input.parse::(), syn::Token![,])?; - Ok(entries.into_iter().collect()) - } else { - let single: LitStr = input.parse()?; - Ok(vec![single]) - } -} - -#[proc_macro] -pub fn vespera(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as AutoRouterInput); - - let folder_name = input - .dir - .map(|f| f.value()) - .unwrap_or_else(|| "routes".to_string()); - - let openapi_file_names = input - .openapi - .unwrap_or_default() - .into_iter() - .map(|f| f.value()) - .collect::>(); - - let title = input.title.map(|t| t.value()); - let version = input.version.map(|v| v.value()); - let docs_url = input.docs_url.map(|u| u.value()); - let redoc_url = input.redoc_url.map(|u| u.value()); - - let folder_path = find_folder_path(&folder_name); - - if !folder_path.exists() { - return syn::Error::new( - Span::call_site(), - format!("Folder not found: {}", folder_name), - ) - .to_compile_error() - .into(); - } - - let mut metadata = match collect_metadata(&folder_path, &folder_name) { - Ok(metadata) => metadata, - Err(e) => { - return syn::Error::new( - Span::call_site(), - format!("Failed to collect metadata: {}", e), - ) - .to_compile_error() - .into(); - } - }; - let schemas = SCHEMA_STORAGE.lock().unwrap().clone(); - - metadata.structs.extend(schemas); - - let mut docs_info = None; - let mut redoc_info = None; - - if !openapi_file_names.is_empty() || docs_url.is_some() || redoc_url.is_some() { - // Generate OpenAPI document using collected metadata - - // Serialize to JSON - let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( - title, version, &metadata, - )) { - Ok(json) => json, - Err(e) => { - return syn::Error::new( - Span::call_site(), - format!("Failed to serialize OpenAPI document: {}", e), - ) - .to_compile_error() - .into(); - } - }; - for openapi_file_name in &openapi_file_names { - // 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!( - "Failed to write OpenAPI document to {}: {}", - openapi_file_name, e - ), - ) - .to_compile_error() - .into(); - } - } - if let Some(docs_url) = docs_url { - docs_info = Some((docs_url, json_str.clone())); - } - if let Some(redoc_url) = redoc_url { - redoc_info = Some((redoc_url, json_str)); - } - } - - generate_router_code(&metadata, docs_info, redoc_info).into() -} - -fn find_folder_path(folder_name: &str) -> std::path::PathBuf { - let root = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - let path = format!("{}/src/{}", root, folder_name); - let path = Path::new(&path); - if path.exists() && path.is_dir() { - return path.to_path_buf(); - } - - Path::new(folder_name).to_path_buf() -} - -fn generate_router_code( - metadata: &CollectedMetadata, - docs_info: Option<(String, String)>, - redoc_info: Option<(String, String)>, -) -> proc_macro2::TokenStream { - let mut router_nests = Vec::new(); - - for route in &metadata.routes { - let http_method = HttpMethod::from(route.method.as_str()); - let method_path = http_method_to_token_stream(http_method); - let path = &route.path; - let module_path = &route.module_path; - let function_name = &route.function_name; - - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend( - module_path - .split("::") - .filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - }) - .collect::>(), - ); - let func_name = syn::Ident::new(function_name, Span::call_site()); - router_nests.push(quote!( - .route(#path, #method_path(#p::#func_name)) - )); - } - - if let Some((docs_url, spec)) = docs_info { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - let html = format!( - r#" - - - - - Swagger UI - - - -
- - - - - - - - -"#, - spec_json = spec - ) - .replace("\n", ""); - - router_nests.push(quote!( - .route(#docs_url, #method_path(|| async { vespera::axum::response::Html(#html) })) - )); - } - - if let Some((redoc_url, spec)) = redoc_info { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - let html = format!( - r#" - - - - - ReDoc - - - - - -
- - - - -"#, - spec_json = spec - ) - .replace("\n", ""); - - router_nests.push(quote!( - .route(#redoc_url, #method_path(|| async { vespera::axum::response::Html(#html) })) - )); - } - - quote! { - vespera::axum::Router::new() - #( #router_nests )* - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - use std::fs; - use tempfile::TempDir; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - #[test] - fn test_generate_router_code_empty() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Should generate empty router - // quote! generates "vespera :: axum :: Router :: new ()" format - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {}", - code - ); - assert!( - !code.contains("route"), - "Code should not contain route, got: {}", - code - ); - - drop(temp_dir); - } - - #[rstest] - #[case::single_get_route( - "routes", - vec![( - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "routes::users::get_users", - )] - #[case::single_post_route( - "routes", - vec![( - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - )], - "post", - "/create-user", - "routes::create_user::create_user", - )] - #[case::single_put_route( - "routes", - vec![( - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - )], - "put", - "/update-user", - "routes::update_user::update_user", - )] - #[case::single_delete_route( - "routes", - vec![( - "delete_user.rs", - r#" -#[route(delete)] -pub fn delete_user() -> String { - "deleted".to_string() -} -"#, - )], - "delete", - "/delete-user", - "routes::delete_user::delete_user", - )] - #[case::single_patch_route( - "routes", - vec![( - "patch_user.rs", - r#" -#[route(patch)] -pub fn patch_user() -> String { - "patched".to_string() -} -"#, - )], - "patch", - "/patch-user", - "routes::patch_user::patch_user", - )] - #[case::route_with_custom_path( - "routes", - vec![( - "users.rs", - r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users/api/users", - "routes::users::get_users", - )] - #[case::nested_module( - "routes", - vec![( - "api/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/users", - "routes::api::users::get_users", - )] - #[case::deeply_nested_module( - "routes", - vec![( - "api/v1/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/v1/users", - "routes::api::v1::users::get_users", - )] - fn test_generate_router_code_single_route( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_path: &str, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - for (filename, content) in files { - create_temp_file(&temp_dir, filename, content); - } - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {}", - code - ); - - // Check route method - assert!( - code.contains(expected_method), - "Code should contain method: {}, got: {}", - expected_method, - code - ); - - // Check route path - assert!( - code.contains(expected_path), - "Code should contain path: {}, got: {}", - expected_path, - code - ); - - // Check function path (quote! adds spaces, so we check for parts) - let function_parts: Vec<&str> = expected_function_path.split("::").collect(); - for part in &function_parts { - if !part.is_empty() { - assert!( - code.contains(part), - "Code should contain function part: {}, got: {}", - part, - code - ); - } - } - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create multiple route files - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check all routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_user")); - assert!(code.contains("update_user")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - assert!(code.contains("put")); - - // Count route calls (quote! generates ". route (" with spaces) - // Count occurrences of ". route (" pattern - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 3, - "Should have 3 route calls, got: {}, code: {}", - route_count, code - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_same_path_different_methods() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create routes with same path but different methods - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} - -#[route(post)] -pub fn create_users() -> String { - "created".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check both routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_users")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - - // Should have 2 routes (quote! generates ". route (" with spaces) - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 2, - "Should have 2 routes, got: {}, code: {}", - route_count, code - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_with_mod_rs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create mod.rs file - create_temp_file( - &temp_dir, - "mod.rs", - r#" -#[route(get)] -pub fn index() -> String { - "index".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("index")); - - // Path should be / (mod.rs maps to root, segments is empty) - // quote! generates "\"/\"" - assert!(code.contains("\"/\"")); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), - None, - None, - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("get_users")); - - // Module path should not have double colons - assert!(!code.contains("::users::users")); - - drop(temp_dir); - } -} +mod args; +mod collector; +mod file_utils; +mod metadata; +mod method; +mod openapi_generator; +mod parser; +mod route; + +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::quote; +use std::path::Path; +use std::sync::{LazyLock, Mutex}; +use syn::LitStr; +use syn::bracketed; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; + +use crate::collector::collect_metadata; +use crate::metadata::{CollectedMetadata, StructMetadata}; +use crate::method::http_method_to_token_stream; +use crate::openapi_generator::generate_openapi_doc_with_metadata; +use vespera_core::route::HttpMethod; + +/// route attribute macro +#[proc_macro_attribute] +pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { + // Validate attribute arguments + if let Err(e) = syn::parse::(attr) { + return e.to_compile_error().into(); + } + + // Validate that item is a function + let item_fn = match syn::parse::(item.clone()) { + Ok(f) => f, + Err(e) => { + return syn::Error::new(e.span(), "route attribute can only be applied to functions") + .to_compile_error() + .into(); + } + }; + + // Validate function is pub + if !matches!(item_fn.vis, syn::Visibility::Public(_)) { + return syn::Error::new_spanned(item_fn.sig.fn_token, "route function must be public") + .to_compile_error() + .into(); + } + + // Validate function is async + if item_fn.sig.asyncness.is_none() { + return syn::Error::new_spanned(item_fn.sig.fn_token, "route function must be async") + .to_compile_error() + .into(); + } + + item +} + +// Schema Storage global variable +static SCHEMA_STORAGE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +/// Derive macro for Schema +#[proc_macro_derive(Schema)] +pub fn derive_schema(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + let name = &input.ident; + let generics = &input.generics; + + let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); + schema_storage.push(StructMetadata { + name: name.to_string(), + definition: quote::quote!(#input).to_string(), + }); + + // Mark both struct and enum as having SchemaBuilder + // For generic types, include the generic parameters in the impl + // The actual schema generation will be done at runtime + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let expanded = quote! { + impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {} + }; + + TokenStream::from(expanded) +} + +struct AutoRouterInput { + dir: Option, + openapi: Option>, + title: Option, + version: Option, + docs_url: Option, + redoc_url: Option, +} + +impl Parse for AutoRouterInput { + fn parse(input: ParseStream) -> syn::Result { + let mut dir = None; + let mut openapi = None; + let mut title = None; + let mut version = None; + let mut docs_url = None; + let mut redoc_url = None; + + while !input.is_empty() { + let lookahead = input.lookahead1(); + + if lookahead.peek(syn::Ident) { + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "dir" => { + input.parse::()?; + dir = Some(input.parse()?); + } + "openapi" => { + openapi = Some(parse_openapi_values(input)?); + } + "docs_url" => { + input.parse::()?; + docs_url = Some(input.parse()?); + } + "redoc_url" => { + input.parse::()?; + redoc_url = Some(input.parse()?); + } + "title" => { + input.parse::()?; + title = Some(input.parse()?); + } + "version" => { + input.parse::()?; + version = Some(input.parse()?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown field: `{}`. Expected `dir` or `openapi`", + ident_str + ), + )); + } + } + } else if lookahead.peek(syn::LitStr) { + // If just a string, treat it as dir (for backward compatibility) + dir = Some(input.parse()?); + } else { + return Err(lookahead.error()); + } + + if input.peek(syn::Token![,]) { + input.parse::()?; + } else { + break; + } + } + + Ok(AutoRouterInput { + dir: dir.or_else(|| { + std::env::var("VESPERA_DIR") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + openapi: openapi.or_else(|| { + std::env::var("VESPERA_OPENAPI") + .map(|f| vec![LitStr::new(&f, Span::call_site())]) + .ok() + }), + title: title.or_else(|| { + std::env::var("VESPERA_TITLE") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + version: version.or_else(|| { + std::env::var("VESPERA_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + docs_url: docs_url.or_else(|| { + std::env::var("VESPERA_DOCS_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + redoc_url: redoc_url.or_else(|| { + std::env::var("VESPERA_REDOC_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + }) + } +} + +fn parse_openapi_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + if input.peek(syn::token::Bracket) { + let content; + let _ = bracketed!(content in input); + let entries: Punctuated = + content.parse_terminated(|input| input.parse::(), syn::Token![,])?; + Ok(entries.into_iter().collect()) + } else { + let single: LitStr = input.parse()?; + Ok(vec![single]) + } +} + +#[proc_macro] +pub fn vespera(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as AutoRouterInput); + + let folder_name = input + .dir + .map(|f| f.value()) + .unwrap_or_else(|| "routes".to_string()); + + let openapi_file_names = input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect::>(); + + let title = input.title.map(|t| t.value()); + let version = input.version.map(|v| v.value()); + let docs_url = input.docs_url.map(|u| u.value()); + let redoc_url = input.redoc_url.map(|u| u.value()); + + let folder_path = find_folder_path(&folder_name); + + if !folder_path.exists() { + return syn::Error::new( + Span::call_site(), + format!("Folder not found: {}", folder_name), + ) + .to_compile_error() + .into(); + } + + let mut metadata = match collect_metadata(&folder_path, &folder_name) { + Ok(metadata) => metadata, + Err(e) => { + return syn::Error::new( + Span::call_site(), + format!("Failed to collect metadata: {}", e), + ) + .to_compile_error() + .into(); + } + }; + let schemas = SCHEMA_STORAGE.lock().unwrap().clone(); + + metadata.structs.extend(schemas); + + let mut docs_info = None; + let mut redoc_info = None; + + if !openapi_file_names.is_empty() || docs_url.is_some() || redoc_url.is_some() { + // Generate OpenAPI document using collected metadata + + // Serialize to JSON + let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( + title, version, &metadata, + )) { + Ok(json) => json, + Err(e) => { + return syn::Error::new( + Span::call_site(), + format!("Failed to serialize OpenAPI document: {}", e), + ) + .to_compile_error() + .into(); + } + }; + for openapi_file_name in &openapi_file_names { + // 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!( + "Failed to write OpenAPI document to {}: {}", + openapi_file_name, e + ), + ) + .to_compile_error() + .into(); + } + } + if let Some(docs_url) = docs_url { + docs_info = Some((docs_url, json_str.clone())); + } + if let Some(redoc_url) = redoc_url { + redoc_info = Some((redoc_url, json_str)); + } + } + + generate_router_code(&metadata, docs_info, redoc_info).into() +} + +fn find_folder_path(folder_name: &str) -> std::path::PathBuf { + let root = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let path = format!("{}/src/{}", root, folder_name); + let path = Path::new(&path); + if path.exists() && path.is_dir() { + return path.to_path_buf(); + } + + Path::new(folder_name).to_path_buf() +} + +fn generate_router_code( + metadata: &CollectedMetadata, + docs_info: Option<(String, String)>, + redoc_info: Option<(String, String)>, +) -> proc_macro2::TokenStream { + let mut router_nests = Vec::new(); + + for route in &metadata.routes { + let http_method = HttpMethod::from(route.method.as_str()); + let method_path = http_method_to_token_stream(http_method); + let path = &route.path; + let module_path = &route.module_path; + let function_name = &route.function_name; + + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend( + module_path + .split("::") + .filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + }) + .collect::>(), + ); + let func_name = syn::Ident::new(function_name, Span::call_site()); + router_nests.push(quote!( + .route(#path, #method_path(#p::#func_name)) + )); + } + + if let Some((docs_url, spec)) = docs_info { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + let html = format!( + r#" + + + + + Swagger UI + + + +
+ + + + + + + + +"#, + spec_json = spec + ) + .replace("\n", ""); + + router_nests.push(quote!( + .route(#docs_url, #method_path(|| async { vespera::axum::response::Html(#html) })) + )); + } + + if let Some((redoc_url, spec)) = redoc_info { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + let html = format!( + r#" + + + + + ReDoc + + + + + +
+ + + + +"#, + spec_json = spec + ) + .replace("\n", ""); + + router_nests.push(quote!( + .route(#redoc_url, #method_path(|| async { vespera::axum::response::Html(#html) })) + )); + } + + quote! { + vespera::axum::Router::new() + #( #router_nests )* + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::fs; + use tempfile::TempDir; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + #[test] + fn test_generate_router_code_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Should generate empty router + // quote! generates "vespera :: axum :: Router :: new ()" format + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {}", + code + ); + assert!( + !code.contains("route"), + "Code should not contain route, got: {}", + code + ); + + drop(temp_dir); + } + + #[rstest] + #[case::single_get_route( + "routes", + vec![( + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/users", + "routes::users::get_users", + )] + #[case::single_post_route( + "routes", + vec![( + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { + "created".to_string() +} +"#, + )], + "post", + "/create-user", + "routes::create_user::create_user", + )] + #[case::single_put_route( + "routes", + vec![( + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { + "updated".to_string() +} +"#, + )], + "put", + "/update-user", + "routes::update_user::update_user", + )] + #[case::single_delete_route( + "routes", + vec![( + "delete_user.rs", + r#" +#[route(delete)] +pub fn delete_user() -> String { + "deleted".to_string() +} +"#, + )], + "delete", + "/delete-user", + "routes::delete_user::delete_user", + )] + #[case::single_patch_route( + "routes", + vec![( + "patch_user.rs", + r#" +#[route(patch)] +pub fn patch_user() -> String { + "patched".to_string() +} +"#, + )], + "patch", + "/patch-user", + "routes::patch_user::patch_user", + )] + #[case::route_with_custom_path( + "routes", + vec![( + "users.rs", + r#" +#[route(get, path = "/api/users")] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/users/api/users", + "routes::users::get_users", + )] + #[case::nested_module( + "routes", + vec![( + "api/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/api/users", + "routes::api::users::get_users", + )] + #[case::deeply_nested_module( + "routes", + vec![( + "api/v1/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/api/v1/users", + "routes::api::v1::users::get_users", + )] + fn test_generate_router_code_single_route( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_path: &str, + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + for (filename, content) in files { + create_temp_file(&temp_dir, filename, content); + } + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {}", + code + ); + + // Check route method + assert!( + code.contains(expected_method), + "Code should contain method: {}, got: {}", + expected_method, + code + ); + + // Check route path + assert!( + code.contains(expected_path), + "Code should contain path: {}, got: {}", + expected_path, + code + ); + + // Check function path (quote! adds spaces, so we check for parts) + let function_parts: Vec<&str> = expected_function_path.split("::").collect(); + for part in &function_parts { + if !part.is_empty() { + assert!( + code.contains(part), + "Code should contain function part: {}, got: {}", + part, + code + ); + } + } + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create multiple route files + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { + "created".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { + "updated".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check all routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_user")); + assert!(code.contains("update_user")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + assert!(code.contains("put")); + + // Count route calls (quote! generates ". route (" with spaces) + // Count occurrences of ". route (" pattern + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 3, + "Should have 3 route calls, got: {}, code: {}", + route_count, code + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_same_path_different_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create routes with same path but different methods + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} + +#[route(post)] +pub fn create_users() -> String { + "created".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check both routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_users")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + + // Should have 2 routes (quote! generates ". route (" with spaces) + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 2, + "Should have 2 routes, got: {}, code: {}", + route_count, code + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create mod.rs file + create_temp_file( + &temp_dir, + "mod.rs", + r#" +#[route(get)] +pub fn index() -> String { + "index".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("index")); + + // Path should be / (mod.rs maps to root, segments is empty) + // quote! generates "\"/\"" + assert!(code.contains("\"/\"")); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name).unwrap(), + None, + None, + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("get_users")); + + // Module path should not have double colons + assert!(!code.contains("::users::users")); + + drop(temp_dir); + } +} diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 4db2b83..4ad55a2 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -6,9 +6,9 @@ pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { let mut doc_lines = Vec::new(); for attr in attrs { - if attr.path().is_ident("doc") { - if let syn::Meta::NameValue(meta_nv) = &attr.meta { - if let syn::Expr::Lit(syn::ExprLit { + if attr.path().is_ident("doc") + && let syn::Meta::NameValue(meta_nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = &meta_nv.value @@ -18,8 +18,6 @@ pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { let trimmed = line.strip_prefix(' ').unwrap_or(&line); doc_lines.push(trimmed.to_string()); } - } - } } if doc_lines.is_empty() { diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index a8a30d2..20c7498 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -1,169 +1,168 @@ -use std::collections::HashMap; - -use serde::Deserialize; -use vespera::{ - Schema, - axum::{Json, extract::Query}, -}; - -use crate::TestStruct; - -pub mod enums; -pub mod error; -pub mod foo; -pub mod generic; -pub mod health; -pub mod path; -pub mod typed_header; -pub mod users; - -/// Health check endpoint -#[vespera::route(get)] -pub async fn root_endpoint() -> &'static str { - "root endpoint" -} - - -/// Hello!! -#[vespera::route(get, path = "/hello", tags = ["hello"])] -pub async fn mod_file_endpoint() -> &'static str { - "mod file endpoint" -} - -#[derive(Deserialize, Schema, Debug)] -pub struct MapQuery { - pub name: String, - pub age: u32, - pub optional_age: Option, -} -#[vespera::route(get, path = "/map-query")] -pub async fn mod_file_with_map_query(Query(query): Query) -> &'static str { - println!("map query: {:?}", query.age); - println!("map query: {:?}", query.name); - println!("map query: {:?}", query.optional_age); - "mod file endpoint" -} - -#[derive(Deserialize, Debug)] -pub struct NoSchemaQuery { - pub name: String, - pub age: u32, - pub optional_age: Option, -} - -#[vespera::route(get, path = "/no-schema-query")] -pub async fn mod_file_with_no_schema_query(Query(query): Query) -> &'static str { - println!("no schema query: {:?}", query.age); - println!("no schema query: {:?}", query.name); - println!("no schema query: {:?}", query.optional_age); - "mod file endpoint" -} - -#[derive(Deserialize, Schema)] -pub struct StructQuery { - pub name: String, - pub age: u32, -} - -#[vespera::route(get, path = "/struct-query")] -pub async fn mod_file_with_struct_query(Query(query): Query) -> String { - format!("name: {}, age: {}", query.name, query.age) -} - -#[derive(Deserialize, Schema)] -pub struct StructBody { - pub name: String, - pub age: u32, -} - -#[vespera::route(post, path = "/struct-body")] -pub async fn mod_file_with_struct_body(Json(body): Json) -> String { - format!("name: {}, age: {}", body.name, body.age) -} - -#[derive(Deserialize, Schema, Debug)] -pub struct StructBodyWithOptional { - pub name: Option, - pub age: Option, -} - -#[vespera::route(post, path = "/struct-body-with-optional")] -pub async fn mod_file_with_struct_body_with_optional( - Json(body): Json, -) -> String { - format!("name: {:?}, age: {:?}", body.name, body.age) -} - -#[derive(Deserialize, Schema)] -pub struct ComplexStructBody { - pub name: String, - pub age: u32, - pub nested_struct: StructBodyWithOptional, - pub array: Vec, - pub map: HashMap, - pub nested_array: Vec, - pub nested_map: HashMap, - pub nested_struct_array: Vec, - pub nested_struct_map: HashMap, - pub nested_struct_array_map: Vec>, - pub nested_struct_map_array: HashMap>, -} - -#[vespera::route(post, path = "/complex-struct-body")] -pub async fn mod_file_with_complex_struct_body(Json(body): Json) -> String { - format!( - "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", - body.name, - body.age, - body.nested_struct, - body.array, - body.map, - body.nested_array, - body.nested_map, - body.nested_struct_array, - body.nested_struct_map, - body.nested_struct_array_map, - body.nested_struct_map_array - ) -} - -#[derive(Deserialize, Schema)] -#[serde(rename_all = "camelCase")] -pub struct ComplexStructBodyWithRename { - pub name: String, - pub age: u32, - pub nested_struct: StructBodyWithOptional, - pub array: Vec, - pub map: HashMap, - pub nested_array: Vec, - pub nested_map: HashMap, - pub nested_struct_array: Vec, - pub nested_struct_map: HashMap, - pub nested_struct_array_map: Vec>, - pub nested_struct_map_array: HashMap>, -} - -#[vespera::route(post, path = "/complex-struct-body-with-rename")] -pub async fn mod_file_with_complex_struct_body_with_rename( - Json(body): Json, -) -> String { - format!( - "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", - body.name, - body.age, - body.nested_struct, - body.array, - body.map, - body.nested_array, - body.nested_map, - body.nested_struct_array, - body.nested_struct_map, - body.nested_struct_array_map, - body.nested_struct_map_array - ) -} - -#[vespera::route(get, path = "/test_struct")] -pub async fn mod_file_with_test_struct(Query(query): Query) -> Json { - Json(query) -} +use std::collections::HashMap; + +use serde::Deserialize; +use vespera::{ + Schema, + axum::{Json, extract::Query}, +}; + +use crate::TestStruct; + +pub mod enums; +pub mod error; +pub mod foo; +pub mod generic; +pub mod health; +pub mod path; +pub mod typed_header; +pub mod users; + +/// Health check endpoint +#[vespera::route(get)] +pub async fn root_endpoint() -> &'static str { + "root endpoint" +} + +/// Hello!! +#[vespera::route(get, path = "/hello", tags = ["hello"])] +pub async fn mod_file_endpoint() -> &'static str { + "mod file endpoint" +} + +#[derive(Deserialize, Schema, Debug)] +pub struct MapQuery { + pub name: String, + pub age: u32, + pub optional_age: Option, +} +#[vespera::route(get, path = "/map-query")] +pub async fn mod_file_with_map_query(Query(query): Query) -> &'static str { + println!("map query: {:?}", query.age); + println!("map query: {:?}", query.name); + println!("map query: {:?}", query.optional_age); + "mod file endpoint" +} + +#[derive(Deserialize, Debug)] +pub struct NoSchemaQuery { + pub name: String, + pub age: u32, + pub optional_age: Option, +} + +#[vespera::route(get, path = "/no-schema-query")] +pub async fn mod_file_with_no_schema_query(Query(query): Query) -> &'static str { + println!("no schema query: {:?}", query.age); + println!("no schema query: {:?}", query.name); + println!("no schema query: {:?}", query.optional_age); + "mod file endpoint" +} + +#[derive(Deserialize, Schema)] +pub struct StructQuery { + pub name: String, + pub age: u32, +} + +#[vespera::route(get, path = "/struct-query")] +pub async fn mod_file_with_struct_query(Query(query): Query) -> String { + format!("name: {}, age: {}", query.name, query.age) +} + +#[derive(Deserialize, Schema)] +pub struct StructBody { + pub name: String, + pub age: u32, +} + +#[vespera::route(post, path = "/struct-body")] +pub async fn mod_file_with_struct_body(Json(body): Json) -> String { + format!("name: {}, age: {}", body.name, body.age) +} + +#[derive(Deserialize, Schema, Debug)] +pub struct StructBodyWithOptional { + pub name: Option, + pub age: Option, +} + +#[vespera::route(post, path = "/struct-body-with-optional")] +pub async fn mod_file_with_struct_body_with_optional( + Json(body): Json, +) -> String { + format!("name: {:?}, age: {:?}", body.name, body.age) +} + +#[derive(Deserialize, Schema)] +pub struct ComplexStructBody { + pub name: String, + pub age: u32, + pub nested_struct: StructBodyWithOptional, + pub array: Vec, + pub map: HashMap, + pub nested_array: Vec, + pub nested_map: HashMap, + pub nested_struct_array: Vec, + pub nested_struct_map: HashMap, + pub nested_struct_array_map: Vec>, + pub nested_struct_map_array: HashMap>, +} + +#[vespera::route(post, path = "/complex-struct-body")] +pub async fn mod_file_with_complex_struct_body(Json(body): Json) -> String { + format!( + "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", + body.name, + body.age, + body.nested_struct, + body.array, + body.map, + body.nested_array, + body.nested_map, + body.nested_struct_array, + body.nested_struct_map, + body.nested_struct_array_map, + body.nested_struct_map_array + ) +} + +#[derive(Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ComplexStructBodyWithRename { + pub name: String, + pub age: u32, + pub nested_struct: StructBodyWithOptional, + pub array: Vec, + pub map: HashMap, + pub nested_array: Vec, + pub nested_map: HashMap, + pub nested_struct_array: Vec, + pub nested_struct_map: HashMap, + pub nested_struct_array_map: Vec>, + pub nested_struct_map_array: HashMap>, +} + +#[vespera::route(post, path = "/complex-struct-body-with-rename")] +pub async fn mod_file_with_complex_struct_body_with_rename( + Json(body): Json, +) -> String { + format!( + "name: {}, age: {}, nested_struct: {:?}, array: {:?}, map: {:?}, nested_array: {:?}, nested_map: {:?}, nested_struct_array: {:?}, nested_struct_map: {:?}, nested_struct_array_map: {:?}, nested_struct_map_array: {:?}", + body.name, + body.age, + body.nested_struct, + body.array, + body.map, + body.nested_array, + body.nested_map, + body.nested_struct_array, + body.nested_struct_map, + body.nested_struct_array_map, + body.nested_struct_map_array + ) +} + +#[vespera::route(get, path = "/test_struct")] +pub async fn mod_file_with_test_struct(Query(query): Query) -> Json { + Json(query) +}