From 4db7350a37cf4dfe6d0088fa244d7719ceac3c36 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Jan 2026 17:44:41 +0900 Subject: [PATCH 1/3] add servers --- README.md | 20 +- crates/vespera_macro/src/lib.rs | 174 +++++++++++++++++- crates/vespera_macro/src/openapi_generator.rs | 34 ++-- .../snapshots/integration_test__openapi.snap | 3 + 4 files changed, 213 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6059263..c4674a4 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,11 @@ let app = vespera!( openapi = "openapi.json", // OpenAPI JSON file path (optional) title = "My API", // API title (optional, default: "API") version = "1.0.0", // API version (optional, default: Cargo.toml version) - docs_url = "/docs" // Swagger UI documentation URL (optional) + docs_url = "/docs", // Swagger UI documentation URL (optional) + servers = [ // Server URLs for OpenAPI (optional) + {url = "https://api.example.com", description = "Production"}, + {url = "http://localhost:3000", description = "Development"} + ] ); ``` @@ -280,6 +284,16 @@ let app = vespera!( - If specified, you can view the API documentation through ReDoc at that path - Example: Setting `redoc_url = "/redoc"` allows viewing documentation at `http://localhost:3000/redoc` +- **`servers`**: Server URLs for OpenAPI (optional, default: `http://localhost:3000`) + - Configures the `servers` field in the OpenAPI document + - Accepts multiple formats: + - Single URL string: `servers = "https://api.example.com"` + - Array of URL strings: `servers = ["https://api.example.com", "http://localhost:3000"]` + - Tuple format with descriptions: `servers = [("https://api.example.com", "Production")]` + - Struct-like format: `servers = [{url = "https://api.example.com", description = "Production"}]` + - Single struct-like: `servers = {url = "https://api.example.com", description = "Production"}` + - Mixed formats in array: `servers = ["http://localhost:3000", ("https://staging.example.com", "Staging"), {url = "https://api.example.com", description = "Production"}]` + #### Environment Variables All macro parameters can also be configured via environment variables. Environment variables are used as fallbacks when the corresponding macro parameter is not specified. @@ -292,6 +306,8 @@ All macro parameters can also be configured via environment variables. Environme | `version` | `VESPERA_VERSION` | API version | | `docs_url` | `VESPERA_DOCS_URL` | Swagger UI documentation URL | | `redoc_url` | `VESPERA_REDOC_URL` | ReDoc documentation URL | +| `servers` | `VESPERA_SERVER_URL` | Server URL (single server) | +| | `VESPERA_SERVER_DESCRIPTION` | Server description (optional, used with `VESPERA_SERVER_URL`) | **Priority Order** (highest to lowest): 1. Macro parameter (e.g., `version = "1.0.0"`) @@ -306,6 +322,8 @@ All macro parameters can also be configured via environment variables. Environme export VESPERA_TITLE="My Production API" export VESPERA_VERSION="2.0.0" export VESPERA_DOCS_URL="/api-docs" +export VESPERA_SERVER_URL="https://api.example.com" +export VESPERA_SERVER_DESCRIPTION="Production Server" ``` ```rust diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 4da5409..3779a4b 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -21,6 +21,7 @@ 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::openapi::Server; use vespera_core::route::HttpMethod; /// route attribute macro @@ -86,6 +87,13 @@ pub fn derive_schema(input: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// Server configuration for OpenAPI +#[derive(Clone)] +struct ServerConfig { + url: String, + description: Option, +} + struct AutoRouterInput { dir: Option, openapi: Option>, @@ -93,6 +101,7 @@ struct AutoRouterInput { version: Option, docs_url: Option, redoc_url: Option, + servers: Option>, } impl Parse for AutoRouterInput { @@ -103,6 +112,7 @@ impl Parse for AutoRouterInput { let mut version = None; let mut docs_url = None; let mut redoc_url = None; + let mut servers = None; while !input.is_empty() { let lookahead = input.lookahead1(); @@ -135,11 +145,14 @@ impl Parse for AutoRouterInput { input.parse::()?; version = Some(input.parse()?); } + "servers" => { + servers = Some(parse_servers_values(input)?); + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown field: `{}`. Expected `dir` or `openapi`", + "unknown field: `{}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, or `servers`", ident_str ), )); @@ -196,6 +209,17 @@ impl Parse for AutoRouterInput { .map(|f| LitStr::new(&f, Span::call_site())) .ok() }), + servers: servers.or_else(|| { + std::env::var("VESPERA_SERVER_URL") + .ok() + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + .map(|url| { + vec![ServerConfig { + url, + description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(), + }] + }) + }), }) } } @@ -215,6 +239,143 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result> { } } +/// Validate that a URL starts with http:// or https:// +fn validate_server_url(url: &LitStr) -> syn::Result { + let url_value = url.value(); + if !url_value.starts_with("http://") && !url_value.starts_with("https://") { + return Err(syn::Error::new( + url.span(), + format!( + "invalid server URL: `{}`. URL must start with `http://` or `https://`", + url_value + ), + )); + } + Ok(url_value) +} + +/// Parse server values in various formats: +/// - `servers = "url"` - single URL +/// - `servers = ["url1", "url2"]` - multiple URLs (strings only) +/// - `servers = [("url", "description")]` - tuple format with descriptions +/// - `servers = [{url = "...", description = "..."}]` - struct-like format +/// - `servers = {url = "...", description = "..."}` - single server struct-like format +fn parse_servers_values(input: ParseStream) -> syn::Result> { + use syn::token::{Brace, Paren}; + + input.parse::()?; + + if input.peek(syn::token::Bracket) { + // Array format: [...] + let content; + let _ = bracketed!(content in input); + + let mut servers = Vec::new(); + + while !content.is_empty() { + if content.peek(Paren) { + // Parse tuple: ("url", "description") + let tuple_content; + syn::parenthesized!(tuple_content in content); + let url: LitStr = tuple_content.parse()?; + let url_value = validate_server_url(&url)?; + let description = if tuple_content.peek(syn::Token![,]) { + tuple_content.parse::()?; + Some(tuple_content.parse::()?.value()) + } else { + None + }; + servers.push(ServerConfig { + url: url_value, + description, + }); + } else if content.peek(Brace) { + // Parse struct-like: {url = "...", description = "..."} + let server = parse_server_struct(&content)?; + servers.push(server); + } else { + // Parse simple string: "url" + let url: LitStr = content.parse()?; + let url_value = validate_server_url(&url)?; + servers.push(ServerConfig { + url: url_value, + description: None, + }); + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(servers) + } else if input.peek(syn::token::Brace) { + // Single struct-like format: servers = {url = "...", description = "..."} + let server = parse_server_struct(input)?; + Ok(vec![server]) + } else { + // Single string: servers = "url" + let single: LitStr = input.parse()?; + let url_value = validate_server_url(&single)?; + Ok(vec![ServerConfig { + url: url_value, + description: None, + }]) + } +} + +/// Parse a single server in struct-like format: {url = "...", description = "..."} +fn parse_server_struct(input: ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut url: Option = None; + let mut description: Option = None; + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "url" => { + content.parse::()?; + let url_lit: LitStr = content.parse()?; + url = Some(validate_server_url(&url_lit)?); + } + "description" => { + content.parse::()?; + description = Some(content.parse::()?.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown field: `{}`. Expected `url` or `description`", + ident_str + ), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + let url = url.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "server config requires `url` field", + ) + })?; + + Ok(ServerConfig { url, description }) +} + #[proc_macro] pub fn vespera(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as AutoRouterInput); @@ -235,6 +396,15 @@ pub fn vespera(input: TokenStream) -> TokenStream { 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 servers = input.servers.map(|svrs| { + svrs.into_iter() + .map(|s| Server { + url: s.url, + description: s.description, + variables: None, + }) + .collect::>() + }); let folder_path = find_folder_path(&folder_name); @@ -270,7 +440,7 @@ pub fn vespera(input: TokenStream) -> TokenStream { // Serialize to JSON let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( - title, version, &metadata, + title, version, servers, &metadata, )) { Ok(json) => json, Err(e) => { diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index eac92e4..b3a91ca 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -17,6 +17,7 @@ use crate::parser::{ pub fn generate_openapi_doc_with_metadata( title: Option, version: Option, + servers: Option>, metadata: &CollectedMetadata, ) -> OpenApi { let mut paths: BTreeMap = BTreeMap::new(); @@ -189,11 +190,13 @@ pub fn generate_openapi_doc_with_metadata( license: None, summary: None, }, - servers: Some(vec![Server { - url: "http://localhost:3000".to_string(), - description: None, - variables: None, - }]), + servers: servers.or_else(|| { + Some(vec![Server { + url: "http://localhost:3000".to_string(), + description: None, + variables: None, + }]) + }), paths, components: Some(Components { schemas: if schemas.is_empty() { @@ -452,7 +455,7 @@ mod tests { fn test_generate_openapi_empty_metadata() { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert_eq!(doc.openapi, OpenApiVersion::V3_1_0); assert_eq!(doc.info.title, "API"); @@ -479,7 +482,7 @@ mod tests { ) { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(title, version, &metadata); + let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata); assert_eq!(doc.info.title, expected_title); assert_eq!(doc.info.version, expected_version); @@ -510,7 +513,7 @@ pub fn get_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert!(doc.paths.contains_key("/users")); let path_item = doc.paths.get("/users").unwrap(); @@ -527,7 +530,7 @@ pub fn get_users() -> String { definition: "struct User { id: i32, name: String }".to_string(), }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -542,7 +545,7 @@ pub fn get_users() -> String { definition: "enum Status { Active, Inactive, Pending }".to_string(), }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -558,7 +561,7 @@ pub fn get_users() -> String { definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -593,7 +596,7 @@ pub fn get_status() -> Status { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); // Check enum schema assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -620,7 +623,7 @@ pub fn get_status() -> Status { }); // This should panic when fallback tries to parse const as struct - let _doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let _doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); } #[test] @@ -655,6 +658,7 @@ pub fn get_user() -> User { let doc = generate_openapi_doc_with_metadata( Some("Test API".to_string()), Some("1.0.0".to_string()), + None, &metadata, ); @@ -711,7 +715,7 @@ pub fn create_user() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); assert_eq!(doc.paths.len(), 1); // Same path, different methods let path_item = doc.paths.get("/users").unwrap(); @@ -780,7 +784,7 @@ pub fn create_user() -> String { } // Should not panic, just skip invalid files - let doc = generate_openapi_doc_with_metadata(None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); // Check struct if expect_struct { diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 819d8db..2a97a57 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -11,6 +11,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "servers": [ { "url": "http://localhost:3000" + }, + { + "url": "https://api.example.com" } ], "paths": { From 1acac7574b8000bad96a22fb55dff43f483722cb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Jan 2026 17:45:08 +0900 Subject: [PATCH 2/3] Add note --- .changepacks/changepack_log_PhGYh4opbd3JnJv_W44xy.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_PhGYh4opbd3JnJv_W44xy.json diff --git a/.changepacks/changepack_log_PhGYh4opbd3JnJv_W44xy.json b/.changepacks/changepack_log_PhGYh4opbd3JnJv_W44xy.json new file mode 100644 index 0000000..a632ce9 --- /dev/null +++ b/.changepacks/changepack_log_PhGYh4opbd3JnJv_W44xy.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"add servers option with description","date":"2026-01-04T08:44:59.920211600Z"} \ No newline at end of file From 065682f5b8b7dcc5101830d56050bc30009b8e01 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Jan 2026 17:46:43 +0900 Subject: [PATCH 3/3] Update review --- .../tests/snapshots/integration_test__openapi.snap | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 2a97a57..819d8db 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -11,9 +11,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "servers": [ { "url": "http://localhost:3000" - }, - { - "url": "https://api.example.com" } ], "paths": {