rustapi_macros/
lib.rs

1//! Procedural macros for RustAPI
2//!
3//! This crate provides the attribute macros used in RustAPI:
4//!
5//! - `#[rustapi::main]` - Main entry point macro
6//! - `#[rustapi::get("/path")]` - GET route handler
7//! - `#[rustapi::post("/path")]` - POST route handler
8//! - `#[rustapi::put("/path")]` - PUT route handler
9//! - `#[rustapi::patch("/path")]` - PATCH route handler
10//! - `#[rustapi::delete("/path")]` - DELETE route handler
11//!
12//! ## Debugging
13//!
14//! Set `RUSTAPI_DEBUG=1` environment variable during compilation to see
15//! expanded macro output for debugging purposes.
16
17use proc_macro::TokenStream;
18use quote::quote;
19use std::collections::HashSet;
20use syn::{
21    parse_macro_input, FnArg, GenericArgument, ItemFn, LitStr, PathArguments, ReturnType, Type,
22};
23
24/// Auto-register a schema type for zero-config OpenAPI.
25///
26/// Attach this to a `struct` or `enum` that also derives `Schema` (utoipa::ToSchema).
27/// This ensures the type is registered into RustAPI's OpenAPI components even if it is
28/// only referenced indirectly (e.g. as a nested field type).
29///
30/// ```rust,ignore
31/// use rustapi_rs::prelude::*;
32///
33/// #[rustapi_rs::schema]
34/// #[derive(Serialize, Schema)]
35/// struct UserInfo { /* ... */ }
36/// ```
37#[proc_macro_attribute]
38pub fn schema(_attr: TokenStream, item: TokenStream) -> TokenStream {
39    let input = parse_macro_input!(item as syn::Item);
40
41    let (ident, generics) = match &input {
42        syn::Item::Struct(s) => (&s.ident, &s.generics),
43        syn::Item::Enum(e) => (&e.ident, &e.generics),
44        _ => {
45            return syn::Error::new_spanned(
46                &input,
47                "#[rustapi_rs::schema] can only be used on structs or enums",
48            )
49            .to_compile_error()
50            .into();
51        }
52    };
53
54    if !generics.params.is_empty() {
55        return syn::Error::new_spanned(
56            generics,
57            "#[rustapi_rs::schema] does not support generic types",
58        )
59        .to_compile_error()
60        .into();
61    }
62
63    let registrar_ident = syn::Ident::new(
64        &format!("__RUSTAPI_AUTO_SCHEMA_{}", ident),
65        proc_macro2::Span::call_site(),
66    );
67
68    let expanded = quote! {
69        #input
70
71        #[allow(non_upper_case_globals)]
72        #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_SCHEMAS)]
73        #[linkme(crate = ::rustapi_rs::__private::linkme)]
74        static #registrar_ident: fn(&mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) =
75            |spec: &mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec| {
76                spec.register_in_place::<#ident>();
77            };
78    };
79
80    debug_output("schema", &expanded);
81    expanded.into()
82}
83
84fn extract_schema_types(ty: &Type, out: &mut Vec<Type>, allow_leaf: bool) {
85    match ty {
86        Type::Reference(r) => extract_schema_types(&r.elem, out, allow_leaf),
87        Type::Path(tp) => {
88            let Some(seg) = tp.path.segments.last() else {
89                return;
90            };
91
92            let ident = seg.ident.to_string();
93
94            let unwrap_first_generic = |out: &mut Vec<Type>| {
95                if let PathArguments::AngleBracketed(args) = &seg.arguments {
96                    if let Some(GenericArgument::Type(inner)) = args.args.first() {
97                        extract_schema_types(inner, out, true);
98                    }
99                }
100            };
101
102            match ident.as_str() {
103                // Request/response wrappers
104                "Json" | "ValidatedJson" | "Created" => {
105                    unwrap_first_generic(out);
106                }
107                // WithStatus<T, CODE>
108                "WithStatus" => {
109                    if let PathArguments::AngleBracketed(args) = &seg.arguments {
110                        if let Some(GenericArgument::Type(inner)) = args.args.first() {
111                            extract_schema_types(inner, out, true);
112                        }
113                    }
114                }
115                // Common combinators
116                "Option" | "Result" => {
117                    if let PathArguments::AngleBracketed(args) = &seg.arguments {
118                        if let Some(GenericArgument::Type(inner)) = args.args.first() {
119                            extract_schema_types(inner, out, allow_leaf);
120                        }
121                    }
122                }
123                _ => {
124                    if allow_leaf {
125                        out.push(ty.clone());
126                    }
127                }
128            }
129        }
130        _ => {}
131    }
132}
133
134fn collect_handler_schema_types(input: &ItemFn) -> Vec<Type> {
135    let mut found: Vec<Type> = Vec::new();
136
137    for arg in &input.sig.inputs {
138        if let FnArg::Typed(pat_ty) = arg {
139            extract_schema_types(&pat_ty.ty, &mut found, false);
140        }
141    }
142
143    if let ReturnType::Type(_, ty) = &input.sig.output {
144        extract_schema_types(ty, &mut found, false);
145    }
146
147    // Dedup by token string.
148    let mut seen = HashSet::<String>::new();
149    found
150        .into_iter()
151        .filter(|t| seen.insert(quote!(#t).to_string()))
152        .collect()
153}
154
155/// Check if RUSTAPI_DEBUG is enabled at compile time
156fn is_debug_enabled() -> bool {
157    std::env::var("RUSTAPI_DEBUG")
158        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
159        .unwrap_or(false)
160}
161
162/// Print debug output if RUSTAPI_DEBUG=1 is set
163fn debug_output(name: &str, tokens: &proc_macro2::TokenStream) {
164    if is_debug_enabled() {
165        eprintln!("\n=== RUSTAPI_DEBUG: {} ===", name);
166        eprintln!("{}", tokens);
167        eprintln!("=== END {} ===\n", name);
168    }
169}
170
171/// Validate route path syntax at compile time
172///
173/// Returns Ok(()) if the path is valid, or Err with a descriptive error message.
174fn validate_path_syntax(path: &str, span: proc_macro2::Span) -> Result<(), syn::Error> {
175    // Path must start with /
176    if !path.starts_with('/') {
177        return Err(syn::Error::new(
178            span,
179            format!("route path must start with '/', got: \"{}\"", path),
180        ));
181    }
182
183    // Check for empty path segments (double slashes)
184    if path.contains("//") {
185        return Err(syn::Error::new(
186            span,
187            format!(
188                "route path contains empty segment (double slash): \"{}\"",
189                path
190            ),
191        ));
192    }
193
194    // Validate path parameter syntax
195    let mut brace_depth = 0;
196    let mut param_start = None;
197
198    for (i, ch) in path.char_indices() {
199        match ch {
200            '{' => {
201                if brace_depth > 0 {
202                    return Err(syn::Error::new(
203                        span,
204                        format!(
205                            "nested braces are not allowed in route path at position {}: \"{}\"",
206                            i, path
207                        ),
208                    ));
209                }
210                brace_depth += 1;
211                param_start = Some(i);
212            }
213            '}' => {
214                if brace_depth == 0 {
215                    return Err(syn::Error::new(
216                        span,
217                        format!(
218                            "unmatched closing brace '}}' at position {} in route path: \"{}\"",
219                            i, path
220                        ),
221                    ));
222                }
223                brace_depth -= 1;
224
225                // Check that parameter name is not empty
226                if let Some(start) = param_start {
227                    let param_name = &path[start + 1..i];
228                    if param_name.is_empty() {
229                        return Err(syn::Error::new(
230                            span,
231                            format!(
232                                "empty parameter name '{{}}' at position {} in route path: \"{}\"",
233                                start, path
234                            ),
235                        ));
236                    }
237                    // Validate parameter name contains only valid identifier characters
238                    if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
239                        return Err(syn::Error::new(
240                            span,
241                            format!(
242                                "invalid parameter name '{{{}}}' at position {} - parameter names must contain only alphanumeric characters and underscores: \"{}\"",
243                                param_name, start, path
244                            ),
245                        ));
246                    }
247                    // Parameter name must not start with a digit
248                    if param_name
249                        .chars()
250                        .next()
251                        .map(|c| c.is_ascii_digit())
252                        .unwrap_or(false)
253                    {
254                        return Err(syn::Error::new(
255                            span,
256                            format!(
257                                "parameter name '{{{}}}' cannot start with a digit at position {}: \"{}\"",
258                                param_name, start, path
259                            ),
260                        ));
261                    }
262                }
263                param_start = None;
264            }
265            // Check for invalid characters in path (outside of parameters)
266            _ if brace_depth == 0 => {
267                // Allow alphanumeric, -, _, ., /, and common URL characters
268                if !ch.is_alphanumeric() && !"-_./*".contains(ch) {
269                    return Err(syn::Error::new(
270                        span,
271                        format!(
272                            "invalid character '{}' at position {} in route path: \"{}\"",
273                            ch, i, path
274                        ),
275                    ));
276                }
277            }
278            _ => {}
279        }
280    }
281
282    // Check for unclosed braces
283    if brace_depth > 0 {
284        return Err(syn::Error::new(
285            span,
286            format!(
287                "unclosed brace '{{' in route path (missing closing '}}'): \"{}\"",
288                path
289            ),
290        ));
291    }
292
293    Ok(())
294}
295
296/// Main entry point macro for RustAPI applications
297///
298/// This macro wraps your async main function with the tokio runtime.
299///
300/// # Example
301///
302/// ```rust,ignore
303/// use rustapi_rs::prelude::*;
304///
305/// #[rustapi::main]
306/// async fn main() -> Result<()> {
307///     RustApi::new()
308///         .mount(hello)
309///         .run("127.0.0.1:8080")
310///         .await
311/// }
312/// ```
313#[proc_macro_attribute]
314pub fn main(_attr: TokenStream, item: TokenStream) -> TokenStream {
315    let input = parse_macro_input!(item as ItemFn);
316
317    let attrs = &input.attrs;
318    let vis = &input.vis;
319    let sig = &input.sig;
320    let block = &input.block;
321
322    let expanded = quote! {
323        #(#attrs)*
324        #[::tokio::main]
325        #vis #sig {
326            #block
327        }
328    };
329
330    debug_output("main", &expanded);
331
332    TokenStream::from(expanded)
333}
334
335/// Internal helper to generate route handler macros
336fn generate_route_handler(method: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
337    let path = parse_macro_input!(attr as LitStr);
338    let input = parse_macro_input!(item as ItemFn);
339
340    let fn_name = &input.sig.ident;
341    let fn_vis = &input.vis;
342    let fn_attrs = &input.attrs;
343    let fn_async = &input.sig.asyncness;
344    let fn_inputs = &input.sig.inputs;
345    let fn_output = &input.sig.output;
346    let fn_block = &input.block;
347    let fn_generics = &input.sig.generics;
348
349    let schema_types = collect_handler_schema_types(&input);
350
351    let path_value = path.value();
352
353    // Validate path syntax at compile time
354    if let Err(err) = validate_path_syntax(&path_value, path.span()) {
355        return err.to_compile_error().into();
356    }
357
358    // Generate a companion module with route info
359    let route_fn_name = syn::Ident::new(&format!("{}_route", fn_name), fn_name.span());
360    // Generate unique name for auto-registration static
361    let auto_route_name = syn::Ident::new(&format!("__AUTO_ROUTE_{}", fn_name), fn_name.span());
362
363    // Generate unique names for schema registration
364    let schema_reg_fn_name =
365        syn::Ident::new(&format!("__{}_register_schemas", fn_name), fn_name.span());
366    let auto_schema_name = syn::Ident::new(&format!("__AUTO_SCHEMA_{}", fn_name), fn_name.span());
367
368    // Pick the right route helper function based on method
369    let route_helper = match method {
370        "GET" => quote!(::rustapi_rs::get_route),
371        "POST" => quote!(::rustapi_rs::post_route),
372        "PUT" => quote!(::rustapi_rs::put_route),
373        "PATCH" => quote!(::rustapi_rs::patch_route),
374        "DELETE" => quote!(::rustapi_rs::delete_route),
375        _ => quote!(::rustapi_rs::get_route),
376    };
377
378    // Extract metadata from attributes to chain builder methods
379    let mut chained_calls = quote!();
380
381    for attr in fn_attrs {
382        // Check for tag, summary, description
383        // Use loose matching on the last segment to handle crate renaming or fully qualified paths
384        if let Some(ident) = attr.path().segments.last().map(|s| &s.ident) {
385            let ident_str = ident.to_string();
386            if ident_str == "tag" {
387                if let Ok(lit) = attr.parse_args::<LitStr>() {
388                    let val = lit.value();
389                    chained_calls = quote! { #chained_calls .tag(#val) };
390                }
391            } else if ident_str == "summary" {
392                if let Ok(lit) = attr.parse_args::<LitStr>() {
393                    let val = lit.value();
394                    chained_calls = quote! { #chained_calls .summary(#val) };
395                }
396            } else if ident_str == "description" {
397                if let Ok(lit) = attr.parse_args::<LitStr>() {
398                    let val = lit.value();
399                    chained_calls = quote! { #chained_calls .description(#val) };
400                }
401            }
402        }
403    }
404
405    let expanded = quote! {
406        // The original handler function
407        #(#fn_attrs)*
408        #fn_vis #fn_async fn #fn_name #fn_generics (#fn_inputs) #fn_output #fn_block
409
410        // Route info function - creates a Route for this handler
411        #[doc(hidden)]
412        #fn_vis fn #route_fn_name() -> ::rustapi_rs::Route {
413            #route_helper(#path_value, #fn_name)
414                #chained_calls
415        }
416
417        // Auto-register route with linkme
418        #[doc(hidden)]
419        #[allow(non_upper_case_globals)]
420        #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_ROUTES)]
421        #[linkme(crate = ::rustapi_rs::__private::linkme)]
422        static #auto_route_name: fn() -> ::rustapi_rs::Route = #route_fn_name;
423
424        // Auto-register referenced schemas with linkme (best-effort)
425        #[doc(hidden)]
426        #[allow(non_snake_case)]
427        fn #schema_reg_fn_name(spec: &mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) {
428            #( spec.register_in_place::<#schema_types>(); )*
429        }
430
431        #[doc(hidden)]
432        #[allow(non_upper_case_globals)]
433        #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_SCHEMAS)]
434        #[linkme(crate = ::rustapi_rs::__private::linkme)]
435        static #auto_schema_name: fn(&mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) = #schema_reg_fn_name;
436    };
437
438    debug_output(&format!("{} {}", method, path_value), &expanded);
439
440    TokenStream::from(expanded)
441}
442
443/// GET route handler macro
444///
445/// # Example
446///
447/// ```rust,ignore
448/// #[rustapi::get("/users")]
449/// async fn list_users() -> Json<Vec<User>> {
450///     Json(vec![])
451/// }
452///
453/// #[rustapi::get("/users/{id}")]
454/// async fn get_user(Path(id): Path<i64>) -> Result<User> {
455///     Ok(User { id, name: "John".into() })
456/// }
457/// ```
458#[proc_macro_attribute]
459pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
460    generate_route_handler("GET", attr, item)
461}
462
463/// POST route handler macro
464#[proc_macro_attribute]
465pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
466    generate_route_handler("POST", attr, item)
467}
468
469/// PUT route handler macro
470#[proc_macro_attribute]
471pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
472    generate_route_handler("PUT", attr, item)
473}
474
475/// PATCH route handler macro
476#[proc_macro_attribute]
477pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
478    generate_route_handler("PATCH", attr, item)
479}
480
481/// DELETE route handler macro
482#[proc_macro_attribute]
483pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
484    generate_route_handler("DELETE", attr, item)
485}
486
487// ============================================
488// Route Metadata Macros
489// ============================================
490
491/// Tag macro for grouping endpoints in OpenAPI documentation
492///
493/// # Example
494///
495/// ```rust,ignore
496/// #[rustapi::get("/users")]
497/// #[rustapi::tag("Users")]
498/// async fn list_users() -> Json<Vec<User>> {
499///     Json(vec![])
500/// }
501/// ```
502#[proc_macro_attribute]
503pub fn tag(attr: TokenStream, item: TokenStream) -> TokenStream {
504    let tag = parse_macro_input!(attr as LitStr);
505    let input = parse_macro_input!(item as ItemFn);
506
507    let attrs = &input.attrs;
508    let vis = &input.vis;
509    let sig = &input.sig;
510    let block = &input.block;
511    let tag_value = tag.value();
512
513    // Add a doc comment with the tag info for documentation
514    let expanded = quote! {
515        #[doc = concat!("**Tag:** ", #tag_value)]
516        #(#attrs)*
517        #vis #sig #block
518    };
519
520    TokenStream::from(expanded)
521}
522
523/// Summary macro for endpoint summary in OpenAPI documentation
524///
525/// # Example
526///
527/// ```rust,ignore
528/// #[rustapi::get("/users")]
529/// #[rustapi::summary("List all users")]
530/// async fn list_users() -> Json<Vec<User>> {
531///     Json(vec![])
532/// }
533/// ```
534#[proc_macro_attribute]
535pub fn summary(attr: TokenStream, item: TokenStream) -> TokenStream {
536    let summary = parse_macro_input!(attr as LitStr);
537    let input = parse_macro_input!(item as ItemFn);
538
539    let attrs = &input.attrs;
540    let vis = &input.vis;
541    let sig = &input.sig;
542    let block = &input.block;
543    let summary_value = summary.value();
544
545    // Add a doc comment with the summary
546    let expanded = quote! {
547        #[doc = #summary_value]
548        #(#attrs)*
549        #vis #sig #block
550    };
551
552    TokenStream::from(expanded)
553}
554
555/// Description macro for detailed endpoint description in OpenAPI documentation
556///
557/// # Example
558///
559/// ```rust,ignore
560/// #[rustapi::get("/users")]
561/// #[rustapi::description("Returns a list of all users in the system. Supports pagination.")]
562/// async fn list_users() -> Json<Vec<User>> {
563///     Json(vec![])
564/// }
565/// ```
566#[proc_macro_attribute]
567pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
568    let desc = parse_macro_input!(attr as LitStr);
569    let input = parse_macro_input!(item as ItemFn);
570
571    let attrs = &input.attrs;
572    let vis = &input.vis;
573    let sig = &input.sig;
574    let block = &input.block;
575    let desc_value = desc.value();
576
577    // Add a doc comment with the description
578    let expanded = quote! {
579        #[doc = ""]
580        #[doc = #desc_value]
581        #(#attrs)*
582        #vis #sig #block
583    };
584
585    TokenStream::from(expanded)
586}