@@ -21,6 +21,7 @@ use crate::collector::collect_metadata;
2121use crate :: metadata:: { CollectedMetadata , StructMetadata } ;
2222use crate :: method:: http_method_to_token_stream;
2323use crate :: openapi_generator:: generate_openapi_doc_with_metadata;
24+ use vespera_core:: openapi:: Server ;
2425use 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+
8997struct 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
98107impl 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]
219380pub 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