Skip to content

Commit 056942e

Browse files
authored
Merge pull request #44 from dev-five-git/add-servers
Add servers
2 parents 3117cd5 + 065682f commit 056942e

4 files changed

Lines changed: 211 additions & 18 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,11 @@ let app = vespera!(
249249
openapi = "openapi.json", // OpenAPI JSON file path (optional)
250250
title = "My API", // API title (optional, default: "API")
251251
version = "1.0.0", // API version (optional, default: Cargo.toml version)
252-
docs_url = "/docs" // Swagger UI documentation URL (optional)
252+
docs_url = "/docs", // Swagger UI documentation URL (optional)
253+
servers = [ // Server URLs for OpenAPI (optional)
254+
{url = "https://api.example.com", description = "Production"},
255+
{url = "http://localhost:3000", description = "Development"}
256+
]
253257
);
254258
```
255259

@@ -280,6 +284,16 @@ let app = vespera!(
280284
- If specified, you can view the API documentation through ReDoc at that path
281285
- Example: Setting `redoc_url = "/redoc"` allows viewing documentation at `http://localhost:3000/redoc`
282286

287+
- **`servers`**: Server URLs for OpenAPI (optional, default: `http://localhost:3000`)
288+
- Configures the `servers` field in the OpenAPI document
289+
- Accepts multiple formats:
290+
- Single URL string: `servers = "https://api.example.com"`
291+
- Array of URL strings: `servers = ["https://api.example.com", "http://localhost:3000"]`
292+
- Tuple format with descriptions: `servers = [("https://api.example.com", "Production")]`
293+
- Struct-like format: `servers = [{url = "https://api.example.com", description = "Production"}]`
294+
- Single struct-like: `servers = {url = "https://api.example.com", description = "Production"}`
295+
- Mixed formats in array: `servers = ["http://localhost:3000", ("https://staging.example.com", "Staging"), {url = "https://api.example.com", description = "Production"}]`
296+
283297
#### Environment Variables
284298

285299
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
292306
| `version` | `VESPERA_VERSION` | API version |
293307
| `docs_url` | `VESPERA_DOCS_URL` | Swagger UI documentation URL |
294308
| `redoc_url` | `VESPERA_REDOC_URL` | ReDoc documentation URL |
309+
| `servers` | `VESPERA_SERVER_URL` | Server URL (single server) |
310+
| | `VESPERA_SERVER_DESCRIPTION` | Server description (optional, used with `VESPERA_SERVER_URL`) |
295311

296312
**Priority Order** (highest to lowest):
297313
1. Macro parameter (e.g., `version = "1.0.0"`)
@@ -306,6 +322,8 @@ All macro parameters can also be configured via environment variables. Environme
306322
export VESPERA_TITLE="My Production API"
307323
export VESPERA_VERSION="2.0.0"
308324
export VESPERA_DOCS_URL="/api-docs"
325+
export VESPERA_SERVER_URL="https://api.example.com"
326+
export VESPERA_SERVER_DESCRIPTION="Production Server"
309327
```
310328

311329
```rust

crates/vespera_macro/src/lib.rs

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::collector::collect_metadata;
2121
use crate::metadata::{CollectedMetadata, StructMetadata};
2222
use crate::method::http_method_to_token_stream;
2323
use crate::openapi_generator::generate_openapi_doc_with_metadata;
24+
use vespera_core::openapi::Server;
2425
use vespera_core::route::HttpMethod;
2526

2627
/// route attribute macro
@@ -86,13 +87,21 @@ pub fn derive_schema(input: TokenStream) -> TokenStream {
8687
TokenStream::from(expanded)
8788
}
8889

90+
/// Server configuration for OpenAPI
91+
#[derive(Clone)]
92+
struct ServerConfig {
93+
url: String,
94+
description: Option<String>,
95+
}
96+
8997
struct AutoRouterInput {
9098
dir: Option<LitStr>,
9199
openapi: Option<Vec<LitStr>>,
92100
title: Option<LitStr>,
93101
version: Option<LitStr>,
94102
docs_url: Option<LitStr>,
95103
redoc_url: Option<LitStr>,
104+
servers: Option<Vec<ServerConfig>>,
96105
}
97106

98107
impl Parse for AutoRouterInput {
@@ -103,6 +112,7 @@ impl Parse for AutoRouterInput {
103112
let mut version = None;
104113
let mut docs_url = None;
105114
let mut redoc_url = None;
115+
let mut servers = None;
106116

107117
while !input.is_empty() {
108118
let lookahead = input.lookahead1();
@@ -135,11 +145,14 @@ impl Parse for AutoRouterInput {
135145
input.parse::<syn::Token![=]>()?;
136146
version = Some(input.parse()?);
137147
}
148+
"servers" => {
149+
servers = Some(parse_servers_values(input)?);
150+
}
138151
_ => {
139152
return Err(syn::Error::new(
140153
ident.span(),
141154
format!(
142-
"unknown field: `{}`. Expected `dir` or `openapi`",
155+
"unknown field: `{}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, or `servers`",
143156
ident_str
144157
),
145158
));
@@ -196,6 +209,17 @@ impl Parse for AutoRouterInput {
196209
.map(|f| LitStr::new(&f, Span::call_site()))
197210
.ok()
198211
}),
212+
servers: servers.or_else(|| {
213+
std::env::var("VESPERA_SERVER_URL")
214+
.ok()
215+
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
216+
.map(|url| {
217+
vec![ServerConfig {
218+
url,
219+
description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(),
220+
}]
221+
})
222+
}),
199223
})
200224
}
201225
}
@@ -215,6 +239,143 @@ fn parse_openapi_values(input: ParseStream) -> syn::Result<Vec<LitStr>> {
215239
}
216240
}
217241

242+
/// Validate that a URL starts with http:// or https://
243+
fn validate_server_url(url: &LitStr) -> syn::Result<String> {
244+
let url_value = url.value();
245+
if !url_value.starts_with("http://") && !url_value.starts_with("https://") {
246+
return Err(syn::Error::new(
247+
url.span(),
248+
format!(
249+
"invalid server URL: `{}`. URL must start with `http://` or `https://`",
250+
url_value
251+
),
252+
));
253+
}
254+
Ok(url_value)
255+
}
256+
257+
/// Parse server values in various formats:
258+
/// - `servers = "url"` - single URL
259+
/// - `servers = ["url1", "url2"]` - multiple URLs (strings only)
260+
/// - `servers = [("url", "description")]` - tuple format with descriptions
261+
/// - `servers = [{url = "...", description = "..."}]` - struct-like format
262+
/// - `servers = {url = "...", description = "..."}` - single server struct-like format
263+
fn parse_servers_values(input: ParseStream) -> syn::Result<Vec<ServerConfig>> {
264+
use syn::token::{Brace, Paren};
265+
266+
input.parse::<syn::Token![=]>()?;
267+
268+
if input.peek(syn::token::Bracket) {
269+
// Array format: [...]
270+
let content;
271+
let _ = bracketed!(content in input);
272+
273+
let mut servers = Vec::new();
274+
275+
while !content.is_empty() {
276+
if content.peek(Paren) {
277+
// Parse tuple: ("url", "description")
278+
let tuple_content;
279+
syn::parenthesized!(tuple_content in content);
280+
let url: LitStr = tuple_content.parse()?;
281+
let url_value = validate_server_url(&url)?;
282+
let description = if tuple_content.peek(syn::Token![,]) {
283+
tuple_content.parse::<syn::Token![,]>()?;
284+
Some(tuple_content.parse::<LitStr>()?.value())
285+
} else {
286+
None
287+
};
288+
servers.push(ServerConfig {
289+
url: url_value,
290+
description,
291+
});
292+
} else if content.peek(Brace) {
293+
// Parse struct-like: {url = "...", description = "..."}
294+
let server = parse_server_struct(&content)?;
295+
servers.push(server);
296+
} else {
297+
// Parse simple string: "url"
298+
let url: LitStr = content.parse()?;
299+
let url_value = validate_server_url(&url)?;
300+
servers.push(ServerConfig {
301+
url: url_value,
302+
description: None,
303+
});
304+
}
305+
306+
if content.peek(syn::Token![,]) {
307+
content.parse::<syn::Token![,]>()?;
308+
} else {
309+
break;
310+
}
311+
}
312+
313+
Ok(servers)
314+
} else if input.peek(syn::token::Brace) {
315+
// Single struct-like format: servers = {url = "...", description = "..."}
316+
let server = parse_server_struct(input)?;
317+
Ok(vec![server])
318+
} else {
319+
// Single string: servers = "url"
320+
let single: LitStr = input.parse()?;
321+
let url_value = validate_server_url(&single)?;
322+
Ok(vec![ServerConfig {
323+
url: url_value,
324+
description: None,
325+
}])
326+
}
327+
}
328+
329+
/// Parse a single server in struct-like format: {url = "...", description = "..."}
330+
fn parse_server_struct(input: ParseStream) -> syn::Result<ServerConfig> {
331+
let content;
332+
syn::braced!(content in input);
333+
334+
let mut url: Option<String> = None;
335+
let mut description: Option<String> = None;
336+
337+
while !content.is_empty() {
338+
let ident: syn::Ident = content.parse()?;
339+
let ident_str = ident.to_string();
340+
341+
match ident_str.as_str() {
342+
"url" => {
343+
content.parse::<syn::Token![=]>()?;
344+
let url_lit: LitStr = content.parse()?;
345+
url = Some(validate_server_url(&url_lit)?);
346+
}
347+
"description" => {
348+
content.parse::<syn::Token![=]>()?;
349+
description = Some(content.parse::<LitStr>()?.value());
350+
}
351+
_ => {
352+
return Err(syn::Error::new(
353+
ident.span(),
354+
format!(
355+
"unknown field: `{}`. Expected `url` or `description`",
356+
ident_str
357+
),
358+
));
359+
}
360+
}
361+
362+
if content.peek(syn::Token![,]) {
363+
content.parse::<syn::Token![,]>()?;
364+
} else {
365+
break;
366+
}
367+
}
368+
369+
let url = url.ok_or_else(|| {
370+
syn::Error::new(
371+
proc_macro2::Span::call_site(),
372+
"server config requires `url` field",
373+
)
374+
})?;
375+
376+
Ok(ServerConfig { url, description })
377+
}
378+
218379
#[proc_macro]
219380
pub fn vespera(input: TokenStream) -> TokenStream {
220381
let input = syn::parse_macro_input!(input as AutoRouterInput);
@@ -235,6 +396,15 @@ pub fn vespera(input: TokenStream) -> TokenStream {
235396
let version = input.version.map(|v| v.value());
236397
let docs_url = input.docs_url.map(|u| u.value());
237398
let redoc_url = input.redoc_url.map(|u| u.value());
399+
let servers = input.servers.map(|svrs| {
400+
svrs.into_iter()
401+
.map(|s| Server {
402+
url: s.url,
403+
description: s.description,
404+
variables: None,
405+
})
406+
.collect::<Vec<_>>()
407+
});
238408

239409
let folder_path = find_folder_path(&folder_name);
240410

@@ -270,7 +440,7 @@ pub fn vespera(input: TokenStream) -> TokenStream {
270440

271441
// Serialize to JSON
272442
let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata(
273-
title, version, &metadata,
443+
title, version, servers, &metadata,
274444
)) {
275445
Ok(json) => json,
276446
Err(e) => {

0 commit comments

Comments
 (0)