Skip to main content

rapina_macros/
lib.rs

1use heck::ToKebabCase;
2use proc_macro::TokenStream;
3use quote::quote;
4use syn::spanned::Spanned;
5use syn::{FnArg, ItemFn, LitStr, Pat};
6
7/// Parsed route macro attribute: `"/path"`, `"/path", group = "/prefix"`,
8/// `"/path", description = "..."`, or any combination thereof.
9struct RouteAttr {
10    path: LitStr,
11    group: Option<LitStr>,
12    description: Option<LitStr>,
13}
14
15impl syn::parse::Parse for RouteAttr {
16    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
17        let path: LitStr = input.parse()?;
18        let mut group: Option<LitStr> = None;
19        let mut description: Option<LitStr> = None;
20
21        while input.peek(syn::Token![,]) {
22            input.parse::<syn::Token![,]>()?;
23            if input.is_empty() {
24                break;
25            }
26            let ident: syn::Ident = input.parse()?;
27            input.parse::<syn::Token![=]>()?;
28            if ident == "group" {
29                let value: LitStr = input.parse()?;
30                group = Some(value);
31            } else if ident == "description" {
32                let value: LitStr = input.parse()?;
33                description = Some(value);
34            } else {
35                return Err(syn::Error::new(
36                    ident.span(),
37                    "expected `group` or `description`",
38                ));
39            }
40        }
41
42        if !input.is_empty() {
43            return Err(input.error("unexpected tokens after route attribute"));
44        }
45        Ok(RouteAttr {
46            path,
47            group,
48            description,
49        })
50    }
51}
52
53/// Join a group prefix with a route path at compile time.
54fn join_paths(prefix: &str, path: &str) -> String {
55    let prefix = prefix.trim_end_matches('/');
56    if path.is_empty() || path == "/" {
57        if prefix.is_empty() {
58            return "/".to_string();
59        }
60        return prefix.to_string();
61    }
62    let path = if path.starts_with('/') {
63        path.to_string()
64    } else {
65        format!("/{path}")
66    };
67    format!("{prefix}{path}")
68}
69
70mod schema;
71
72/// Registers a GET route handler.
73///
74/// # Syntax
75///
76/// ```ignore
77/// #[get("/users")]
78/// async fn list_users() -> Json<Vec<User>> { /* ... */ }
79///
80/// // Single path parameter:
81/// #[get("/users/:id")]
82/// async fn get_user(id: Path<u64>) -> Json<User> { /* ... */ }
83///
84/// // Multiple path parameters — tuple, positional (left to right in pattern):
85/// #[get("/orgs/:org_id/teams/:team_id")]
86/// async fn get_team(Path((org_id, team_id)): Path<(u64, u64)>) -> Json<Team> { /* ... */ }
87///
88/// // With a group prefix (registers at /api/users):
89/// #[get("/users", group = "/api")]
90/// async fn list_users() -> Json<Vec<User>> { /* ... */ }
91/// ```
92///
93/// The `group` parameter joins the prefix with the path at compile time,
94/// so the handler is registered at the full path during auto-discovery.
95#[proc_macro_attribute]
96pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
97    route_macro("GET", attr, item)
98}
99
100/// Registers a POST route handler.
101///
102/// See [`get`] for syntax details including the optional `group` parameter.
103#[proc_macro_attribute]
104pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
105    route_macro("POST", attr, item)
106}
107
108/// Registers a PUT route handler.
109///
110/// See [`get`] for syntax details including the optional `group` parameter.
111#[proc_macro_attribute]
112pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
113    route_macro("PUT", attr, item)
114}
115
116/// Registers a PATCH route handler.
117///
118/// # Example
119///
120/// ```ignore
121/// #[patch("/users/:id")]
122/// async fn update_user(Path(id): Path<u64>) -> Json<User> { /* ... */ }
123/// ```
124///
125/// See [`get`] for syntax details including the optional `group` parameter.
126#[proc_macro_attribute]
127pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
128    route_macro("PATCH", attr, item)
129}
130
131/// Registers a DELETE route handler.
132///
133/// See [`get`] for syntax details including the optional `group` parameter.
134#[proc_macro_attribute]
135pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
136    route_macro("DELETE", attr, item)
137}
138
139/// Marks a route as public (no authentication required).
140///
141/// When authentication is enabled via `Rapina::with_auth()`, all routes
142/// require a valid JWT token by default. Use `#[public]` to allow
143/// unauthenticated access to specific routes.
144///
145/// # Example
146///
147/// ```ignore
148/// use rapina::prelude::*;
149///
150/// #[public]
151/// #[get("/health")]
152/// async fn health() -> &'static str {
153///     "ok"
154/// }
155///
156/// #[public]
157/// #[post("/login")]
158/// async fn login(body: Json<LoginRequest>) -> Result<Json<TokenResponse>> {
159///     // ... authenticate and return token
160/// }
161/// ```
162///
163/// Note: Routes starting with `/__rapina` are automatically public.
164#[proc_macro_attribute]
165pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
166    let func: ItemFn = syn::parse(item.clone()).expect("#[public] must be applied to a function");
167    let func_name_str = func.sig.ident.to_string();
168    let item2: proc_macro2::TokenStream = item.into();
169    quote! {
170        #item2
171        rapina::inventory::submit! {
172            rapina::discovery::PublicMarker {
173                handler_name: #func_name_str,
174            }
175        }
176    }
177    .into()
178}
179
180fn route_macro_core(
181    method: &str,
182    attr: proc_macro2::TokenStream,
183    item: proc_macro2::TokenStream,
184) -> proc_macro2::TokenStream {
185    let route_attr: RouteAttr = syn::parse2(attr).expect("expected path as string literal");
186    let path_str = if let Some(ref group) = route_attr.group {
187        let g = group.value();
188        assert!(
189            g.starts_with('/'),
190            "group prefix must start with `/`, got: {g:?}"
191        );
192        join_paths(&g, &route_attr.path.value())
193    } else {
194        route_attr.path.value()
195    };
196    let mut func: ItemFn = syn::parse2(item).expect("expected function");
197
198    let func_name = &func.sig.ident;
199    let func_name_str = func_name.to_string();
200    let func_vis = &func.vis;
201
202    // Extract #[public] attribute if present (when #[public] is below the route macro)
203    let is_public = extract_public_attr(&mut func.attrs);
204
205    // Resolve description: explicit attr wins, then first rustdoc line, then None
206    let description_value: Option<String> = route_attr
207        .description
208        .as_ref()
209        .map(|l| l.value())
210        .or_else(|| extract_doc_description(&func.attrs));
211
212    // Extract #[errors(ErrorType)] attribute if present
213    let error_type = extract_errors_attr(&mut func.attrs);
214
215    // Extract #[cache(ttl = N)] attribute if present
216    let cache_ttl = extract_cache_attr(&mut func.attrs);
217
218    let error_responses_impl = if let Some(err_type) = &error_type {
219        quote! {
220            fn error_responses() -> Vec<rapina::error::ErrorVariant> {
221                <#err_type as rapina::error::DocumentedError>::error_variants()
222            }
223        }
224    } else {
225        quote! {}
226    };
227
228    // Extract return type for schema generation
229    let response_schema_impl = if let syn::ReturnType::Type(_, return_type) = &func.sig.output {
230        if let Some(inner_type) = extract_json_inner_type(return_type) {
231            quote! {
232                fn response_schema() -> Option<serde_json::Value> {
233                    Some(rapina::openapi_schema_for::<#inner_type>())
234                }
235            }
236        } else {
237            quote! {}
238        }
239    } else {
240        quote! {}
241    };
242
243    // Extract request body type and content type for schema generation.
244    // Only generate requestBody for POST, PUT, and PATCH methods per OpenAPI spec.
245    let (request_schema_impl, request_content_type_impl, request_body_required_impl) =
246        if matches!(method, "POST" | "PUT" | "PATCH") {
247            if let Some(meta) = extract_request_body_meta(&func.sig.inputs) {
248                let inner_type = meta.inner_type;
249                let content_type = meta.content_type;
250                let required = meta.required;
251                (
252                    quote! {
253                        fn request_schema() -> Option<serde_json::Value> {
254                            Some(rapina::openapi_schema_for::<#inner_type>())
255                        }
256                    },
257                    quote! {
258                        fn request_content_type() -> Option<&'static str> {
259                            Some(#content_type)
260                        }
261                    },
262                    quote! {
263                        fn request_body_required() -> Option<bool> {
264                            Some(#required)
265                        }
266                    },
267                )
268            } else {
269                (quote! {}, quote! {}, quote! {})
270            }
271        } else {
272            (quote! {}, quote! {}, quote! {})
273        };
274
275    // Collect header params (also strips #[header("name")] attrs from inputs)
276    let header_params = match collect_header_params(&mut func.sig.inputs) {
277        Ok(p) => p,
278        Err(e) => return e.to_compile_error(),
279    };
280
281    // Build an index: arg_idx → &HeaderParamMeta for O(1) lookup during codegen
282    let header_by_arg: std::collections::HashMap<usize, &HeaderParamMeta> =
283        header_params.iter().map(|p| (p.arg_idx, p)).collect();
284
285    // Build header_parameters() impl for the Handler trait
286    let header_parameters_impl = if header_params.is_empty() {
287        quote! {}
288    } else {
289        let entries = header_params.iter().map(|p| {
290            let name = &p.name;
291            let required = p.required;
292            quote! {
293                rapina::discovery::HeaderParamInfo {
294                    name: #name.to_string(),
295                    required: #required,
296                }
297            }
298        });
299        quote! {
300            fn header_parameters() -> Vec<rapina::discovery::HeaderParamInfo> {
301                vec![#(#entries),*]
302            }
303        }
304    };
305
306    // Build description() impl for the Handler trait
307    let description_impl = if let Some(ref desc) = description_value {
308        quote! {
309            fn description() -> Option<&'static str> {
310                Some(#desc)
311            }
312        }
313    } else {
314        quote! {}
315    };
316
317    let args: Vec<_> = func.sig.inputs.iter().collect();
318
319    // Extract return type for type annotation (helps with type inference in async blocks)
320    let return_type_annotation = match &func.sig.output {
321        syn::ReturnType::Type(_, ty) => quote! { : #ty },
322        syn::ReturnType::Default => quote! {},
323    };
324
325    // Optional cache TTL header injection
326    let cache_header_injection = if let Some(ttl) = cache_ttl {
327        let ttl_str = ttl.to_string();
328        quote! {
329            let mut __rapina_response = __rapina_response;
330            __rapina_response.headers_mut().insert(
331                "x-rapina-cache-ttl",
332                rapina::http::HeaderValue::from_static(#ttl_str),
333            );
334        }
335    } else {
336        quote! {}
337    };
338
339    // Build the handler body
340    // Use __rapina_ prefix for internal variables to avoid shadowing user's variables
341    let handler_body = if args.is_empty() {
342        let inner_block = &func.block;
343        quote! {
344            let __rapina_result #return_type_annotation = (async #inner_block).await;
345            let __rapina_response = rapina::response::IntoResponse::into_response(__rapina_result);
346            #cache_header_injection
347            __rapina_response
348        }
349    } else {
350        let inner_block = &func.block;
351
352        // Check if all args are header extractors (so we never need to split req into parts)
353        let all_headers = args.iter().all(|arg| {
354            if let FnArg::Typed(pt) = arg {
355                detect_header_type(&pt.ty).is_some()
356            } else {
357                false
358            }
359        });
360
361        // Check if the single arg is a header type
362        let single_is_header = args.len() == 1
363            && args.first().is_some_and(|arg| {
364                if let FnArg::Typed(pt) = arg {
365                    detect_header_type(&pt.ty).is_some()
366                } else {
367                    false
368                }
369            });
370
371        if args.len() == 1 && !single_is_header {
372            // Single non-header arg: pass request directly to FromRequest
373            let arg = &args[0];
374            if let FnArg::Typed(pat_type) = arg {
375                let pat = &pat_type.pat;
376                let arg_type = &pat_type.ty;
377                let tmp = syn::Ident::new("__rapina_arg_0", proc_macro2::Span::call_site());
378                quote! {
379                    let #tmp = match <#arg_type as rapina::extract::FromRequest>::from_request(__rapina_req, &__rapina_params, &__rapina_state).await {
380                        Ok(v) => v,
381                        Err(e) => return rapina::response::IntoResponse::into_response(e),
382                    };
383                    let #pat = #tmp;
384                    let __rapina_result #return_type_annotation = (async #inner_block).await;
385                    let __rapina_response = rapina::response::IntoResponse::into_response(__rapina_result);
386                    #cache_header_injection
387                    __rapina_response
388                }
389            } else {
390                unreachable!("handler argument must be a typed pattern")
391            }
392        } else if all_headers {
393            // All args are header extractors — extract from parts, no body split needed
394            let mut header_extractions = Vec::new();
395            for (i, arg) in args.iter().enumerate() {
396                if let FnArg::Typed(pat_type) = arg {
397                    let pat = &pat_type.pat;
398                    let tmp = syn::Ident::new(
399                        &format!("__rapina_arg_{}", i),
400                        proc_macro2::Span::call_site(),
401                    );
402                    let meta = header_by_arg.get(&i).expect("all_headers: missing meta");
403                    header_extractions.push(gen_header_extraction(
404                        &meta.inner_type,
405                        meta.required,
406                        &meta.name,
407                        &tmp,
408                    ));
409                    header_extractions.push(quote! { let #pat = #tmp; });
410                }
411            }
412            quote! {
413                let (__rapina_parts, _) = __rapina_req.into_parts();
414                #(#header_extractions)*
415                let __rapina_result #return_type_annotation = (async #inner_block).await;
416                let __rapina_response = rapina::response::IntoResponse::into_response(__rapina_result);
417                #cache_header_injection
418                __rapina_response
419            }
420        } else {
421            // Multiple args: all but last use FromRequestParts (or header extraction), last uses FromRequest
422            let mut parts_extractions = Vec::new();
423
424            for (i, arg) in args[..args.len() - 1].iter().enumerate() {
425                if let FnArg::Typed(pat_type) = arg {
426                    let pat = &pat_type.pat;
427                    let arg_type = &pat_type.ty;
428                    let tmp = syn::Ident::new(
429                        &format!("__rapina_arg_{}", i),
430                        proc_macro2::Span::call_site(),
431                    );
432                    if detect_header_type(arg_type).is_some() {
433                        let meta = header_by_arg.get(&i).expect("mixed: missing meta");
434                        parts_extractions.push(gen_header_extraction(
435                            &meta.inner_type,
436                            meta.required,
437                            &meta.name,
438                            &tmp,
439                        ));
440                        parts_extractions.push(quote! { let #pat = #tmp; });
441                    } else {
442                        parts_extractions.push(quote! {
443                            let #tmp = match <#arg_type as rapina::extract::FromRequestParts>::from_request_parts(&__rapina_parts, &__rapina_params, &__rapina_state).await {
444                                Ok(v) => v,
445                                Err(e) => return rapina::response::IntoResponse::into_response(e),
446                            };
447                            let #pat = #tmp;
448                        });
449                    }
450                }
451            }
452
453            let last_arg = args.last().unwrap();
454            let last_extraction = if let FnArg::Typed(pat_type) = last_arg {
455                let pat = &pat_type.pat;
456                let arg_type = &pat_type.ty;
457                let last_idx = args.len() - 1;
458                let tmp = syn::Ident::new(
459                    &format!("__rapina_arg_{}", last_idx),
460                    proc_macro2::Span::call_site(),
461                );
462                if detect_header_type(arg_type).is_some() {
463                    let meta = header_by_arg
464                        .get(&last_idx)
465                        .expect("last arg: missing meta");
466                    let header_extr =
467                        gen_header_extraction(&meta.inner_type, meta.required, &meta.name, &tmp);
468                    quote! {
469                        #header_extr
470                        let #pat = #tmp;
471                        // Reconstruct the request (body not consumed for header-only last arg)
472                        let _ = __rapina_body;
473                    }
474                } else {
475                    quote! {
476                        let __rapina_req = rapina::http::Request::from_parts(__rapina_parts, __rapina_body);
477                        let #tmp = match <#arg_type as rapina::extract::FromRequest>::from_request(__rapina_req, &__rapina_params, &__rapina_state).await {
478                            Ok(v) => v,
479                            Err(e) => return rapina::response::IntoResponse::into_response(e),
480                        };
481                        let #pat = #tmp;
482                    }
483                }
484            } else {
485                unreachable!("handler argument must be a typed pattern")
486            };
487
488            quote! {
489                let (__rapina_parts, __rapina_body) = __rapina_req.into_parts();
490                #(#parts_extractions)*
491                #last_extraction
492                let __rapina_result #return_type_annotation = (async #inner_block).await;
493                let __rapina_response = rapina::response::IntoResponse::into_response(__rapina_result);
494                #cache_header_injection
495                __rapina_response
496            }
497        }
498    };
499
500    // Build the router method call for the register function
501    let router_method = syn::Ident::new(&method.to_lowercase(), proc_macro2::Span::call_site());
502    let register_fn_name = syn::Ident::new(
503        &format!("__rapina_register_{}", func_name_str),
504        proc_macro2::Span::call_site(),
505    );
506
507    // Generate the struct, Handler impl, and inventory submission
508    quote! {
509        #[derive(Clone, Copy)]
510        #[allow(non_camel_case_types)]
511        #func_vis struct #func_name;
512
513        impl rapina::handler::Handler for #func_name {
514            const NAME: &'static str = #func_name_str;
515
516            #response_schema_impl
517            #request_schema_impl
518            #request_content_type_impl
519            #request_body_required_impl
520            #error_responses_impl
521            #header_parameters_impl
522            #description_impl
523
524            fn call(
525                &self,
526                __rapina_req: rapina::hyper::Request<rapina::hyper::body::Incoming>,
527                __rapina_params: rapina::extract::PathParams,
528                __rapina_state: std::sync::Arc<rapina::state::AppState>,
529            ) -> std::pin::Pin<Box<dyn std::future::Future<Output = rapina::hyper::Response<rapina::response::BoxBody>> + Send>> {
530                Box::pin(async move {
531                    #handler_body
532                })
533            }
534        }
535
536        #[doc(hidden)]
537        fn #register_fn_name(__rapina_router: rapina::router::Router) -> rapina::router::Router {
538            __rapina_router.#router_method(#path_str, #func_name)
539        }
540
541        rapina::inventory::submit! {
542            rapina::discovery::RouteDescriptor {
543                method: #method,
544                path: #path_str,
545                handler_name: #func_name_str,
546                is_public: #is_public,
547                response_schema: <#func_name as rapina::handler::Handler>::response_schema,
548                request_schema: <#func_name as rapina::handler::Handler>::request_schema,
549                request_content_type: <#func_name as rapina::handler::Handler>::request_content_type,
550                request_body_required: <#func_name as rapina::handler::Handler>::request_body_required,
551                error_responses: <#func_name as rapina::handler::Handler>::error_responses,
552                header_parameters: <#func_name as rapina::handler::Handler>::header_parameters,
553                description: <#func_name as rapina::handler::Handler>::description,
554                register: #register_fn_name,
555            }
556        }
557    }
558}
559
560/// Extracts the inner type from Json<T> wrapper for schema generation
561fn extract_json_inner_type(return_type: &syn::Type) -> Option<proc_macro2::TokenStream> {
562    if let syn::Type::Path(type_path) = return_type
563        && let Some(last_segment) = type_path.path.segments.last()
564    {
565        // Direct Json<T>
566        if last_segment.ident == "Json"
567            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
568            && let Some(syn::GenericArgument::Type(inner_type)) = args.args.first()
569        {
570            return Some(quote!(#inner_type));
571        }
572
573        // Result<Json<T>> or Result<Json<T>, E>
574        if last_segment.ident == "Result"
575            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
576            && let Some(syn::GenericArgument::Type(ok_type)) = args.args.first()
577        {
578            return extract_json_inner_type(ok_type);
579        }
580    }
581    None
582}
583
584/// Extracts the request body metadata from handler function arguments.
585/// Supports Json<T>, Form<T>, Validated<Json<T>>, and Validated<Form<T>>.
586fn extract_request_body_meta(
587    inputs: &syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]>,
588) -> Option<RequestBodyMeta> {
589    for arg in inputs.iter() {
590        if let syn::FnArg::Typed(pat_type) = arg {
591            if let Some(meta) = extract_body_inner_type(&pat_type.ty) {
592                return Some(meta);
593            }
594        }
595    }
596    None
597}
598
599/// Information about a request body extractor.
600struct RequestBodyMeta {
601    inner_type: proc_macro2::TokenStream,
602    content_type: &'static str,
603    required: bool,
604}
605
606/// Extracts the inner type and content type from Json<T>, Form<T>, Validated<Json<T>>/Validated<Form<T>>,
607/// or Option<Json<T>>/Option<Form<T>>.
608fn extract_body_inner_type(ty: &syn::Type) -> Option<RequestBodyMeta> {
609    if let syn::Type::Path(type_path) = ty
610        && let Some(last_segment) = type_path.path.segments.last()
611    {
612        // Direct Json<T>
613        if last_segment.ident == "Json"
614            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
615            && let Some(syn::GenericArgument::Type(inner_type)) = args.args.first()
616        {
617            return Some(RequestBodyMeta {
618                inner_type: quote!(#inner_type),
619                content_type: "application/json",
620                required: true,
621            });
622        }
623        // Direct Form<T>
624        if last_segment.ident == "Form"
625            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
626            && let Some(syn::GenericArgument::Type(inner_type)) = args.args.first()
627        {
628            return Some(RequestBodyMeta {
629                inner_type: quote!(#inner_type),
630                content_type: "application/x-www-form-urlencoded",
631                required: true,
632            });
633        }
634        // Validated<Json<T>> or Validated<Form<T>>
635        if last_segment.ident == "Validated"
636            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
637            && let Some(syn::GenericArgument::Type(inner_extractor)) = args.args.first()
638        {
639            return extract_body_inner_type(inner_extractor);
640        }
641        // Option<Json<T>> or Option<Form<T>> - optional request body
642        if last_segment.ident == "Option"
643            && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments
644            && let Some(syn::GenericArgument::Type(inner_extractor)) = args.args.first()
645        {
646            if let Some(mut meta) = extract_body_inner_type(inner_extractor) {
647                meta.required = false;
648                return Some(meta);
649            }
650        }
651    }
652    None
653}
654
655/// Extract the first non-empty line from `///` doc comments on a function.
656fn extract_doc_description(attrs: &[syn::Attribute]) -> Option<String> {
657    for attr in attrs {
658        if !attr.path().is_ident("doc") {
659            continue;
660        }
661        if let syn::Meta::NameValue(nv) = &attr.meta {
662            if let syn::Expr::Lit(syn::ExprLit {
663                lit: syn::Lit::Str(s),
664                ..
665            }) = &nv.value
666            {
667                let line = s.value();
668                let trimmed = line.trim();
669                if !trimmed.is_empty() {
670                    return Some(trimmed.to_string());
671                }
672            }
673        }
674    }
675    None
676}
677
678/// Extract #[errors(ErrorType)] attribute from function attributes, removing it if found.
679fn extract_errors_attr(attrs: &mut Vec<syn::Attribute>) -> Option<syn::Type> {
680    let idx = attrs
681        .iter()
682        .position(|attr| attr.path().is_ident("errors"))?;
683    let attr = attrs.remove(idx);
684    let err_type: syn::Type = attr.parse_args().expect("expected #[errors(ErrorType)]");
685    Some(err_type)
686}
687
688/// Extract #[cache(ttl = N)] attribute from function attributes, removing it if found.
689fn extract_cache_attr(attrs: &mut Vec<syn::Attribute>) -> Option<u64> {
690    let idx = attrs
691        .iter()
692        .position(|attr| attr.path().is_ident("cache"))?;
693    let attr = attrs.remove(idx);
694
695    let mut ttl: Option<u64> = None;
696    attr.parse_nested_meta(|meta| {
697        if meta.path.is_ident("ttl") {
698            let value = meta.value()?;
699            let lit: syn::LitInt = value.parse()?;
700            ttl = Some(lit.base10_parse()?);
701            Ok(())
702        } else {
703            Err(meta.error("expected `ttl`"))
704        }
705    })
706    .expect("expected #[cache(ttl = N)]");
707
708    ttl
709}
710
711/// Extract #[public] attribute from function attributes, removing it if found.
712fn extract_public_attr(attrs: &mut Vec<syn::Attribute>) -> bool {
713    if let Some(idx) = attrs.iter().position(|attr| attr.path().is_ident("public")) {
714        attrs.remove(idx);
715        true
716    } else {
717        false
718    }
719}
720
721/// Generate the extraction code for a `Header<T>` or `Option<Header<T>>` parameter.
722///
723/// `header_name` is the resolved HTTP header name (kebab-case, possibly from
724/// an explicit `#[header("name")]` attribute).
725fn gen_header_extraction(
726    inner_type: &syn::Type,
727    required: bool,
728    header_name: &str,
729    tmp: &syn::Ident,
730) -> proc_macro2::TokenStream {
731    if required {
732        quote! {
733            let #tmp = match rapina::extract::extract_header::<#inner_type>(&__rapina_parts, #header_name) {
734                Ok(v) => rapina::extract::Header::new(#header_name, v),
735                Err(e) => return rapina::response::IntoResponse::into_response(e),
736            };
737        }
738    } else {
739        quote! {
740            let #tmp = match rapina::extract::extract_optional_header::<#inner_type>(&__rapina_parts, #header_name) {
741                Ok(Some(v)) => Some(rapina::extract::Header::new(#header_name, v)),
742                Ok(None) => None,
743                Err(e) => return rapina::response::IntoResponse::into_response(e),
744            };
745        }
746    }
747}
748
749/// Metadata about a single `Header<T>` or `Option<Header<T>>` parameter.
750struct HeaderParamMeta {
751    /// Zero-based index of this param in the handler's argument list.
752    arg_idx: usize,
753    /// The HTTP header name (e.g. "x-request-id").
754    name: String,
755    /// Whether the parameter is required (`Header<T>`) or optional (`Option<Header<T>>`).
756    required: bool,
757    /// The inner `T` type (for generating the extraction call).
758    inner_type: syn::Type,
759}
760
761/// Extract `#[header("name")]` attribute from a parameter's attribute list.
762///
763/// Returns the explicit header name if present, removing the attribute.
764fn extract_header_attr(attrs: &mut Vec<syn::Attribute>) -> Option<String> {
765    let idx = attrs
766        .iter()
767        .position(|attr| attr.path().is_ident("header"))?;
768    let attr = attrs.remove(idx);
769    let lit: LitStr = attr.parse_args().expect("expected #[header(\"name\")]");
770    Some(lit.value())
771}
772
773/// Detect if `ty` is `Header<T>` (required) or `Option<Header<T>>` (optional).
774///
775/// Returns `Some((inner_type, required))` on match, `None` otherwise.
776///
777/// Matches `Header<T>` (bare or path-qualified as `extract::Header<T>` /
778/// `rapina::extract::Header<T>`).  Any other qualifying path (e.g.
779/// `my_crate::Header<T>`) returns `None`, so user-defined types named `Header`
780/// fall through to normal handling instead of producing a confusing compile
781/// error from macro-generated code.
782fn detect_header_type(ty: &syn::Type) -> Option<(syn::Type, bool)> {
783    let syn::Type::Path(type_path) = ty else {
784        return None;
785    };
786    let last = type_path.path.segments.last()?;
787
788    // Direct Header<T>
789    if last.ident == "Header" {
790        // When the type is qualified (e.g. `foo::Header`), only treat it as
791        // rapina's Header if the leading path is a known rapina prefix.
792        // Bare `Header` (imported via prelude) has no leading segments and
793        // is always accepted.
794        let segments: Vec<_> = type_path.path.segments.iter().collect();
795        let is_rapina_header = match segments.len() {
796            1 => true,                                                            // bare `Header`
797            2 => segments[0].ident == "extract", // `extract::Header`
798            3 => segments[0].ident == "rapina" && segments[1].ident == "extract", // `rapina::extract::Header`
799            _ => false,
800        };
801        if is_rapina_header {
802            if let syn::PathArguments::AngleBracketed(args) = &last.arguments {
803                if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
804                    return Some((inner.clone(), true));
805                }
806            }
807        }
808    }
809
810    // Option<Header<T>>
811    if last.ident == "Option" {
812        if let syn::PathArguments::AngleBracketed(args) = &last.arguments {
813            if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
814                if let Some((inner_t, _)) = detect_header_type(inner) {
815                    return Some((inner_t, false));
816                }
817            }
818        }
819    }
820
821    None
822}
823
824/// Collect all `Header<T>` / `Option<Header<T>>` parameters from handler inputs.
825///
826/// Also strips any `#[header("name")]` attributes from the parameters
827/// (they are not valid Rust attributes and must be removed before codegen).
828fn collect_header_params(
829    inputs: &mut syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]>,
830) -> syn::Result<Vec<HeaderParamMeta>> {
831    let mut params = Vec::new();
832
833    for (arg_idx, arg) in inputs.iter_mut().enumerate() {
834        let syn::FnArg::Typed(pat_type) = arg else {
835            continue;
836        };
837
838        let Some((inner_type, required)) = detect_header_type(&pat_type.ty) else {
839            continue;
840        };
841
842        // Check for explicit #[header("name")] override on the parameter
843        let explicit_name = extract_header_attr(&mut pat_type.attrs);
844
845        // Derive header name from snake_case param name, or use explicit override.
846        let name = if let Some(n) = explicit_name {
847            n
848        } else if let Pat::Ident(pat_ident) = &*pat_type.pat {
849            pat_ident.ident.to_string().to_kebab_case()
850        } else {
851            // Destructure pattern — can't infer name, user must use #[header("name")]
852            return Err(syn::Error::new_spanned(
853                &*pat_type.pat,
854                "Header<T> parameter with a destructure pattern must have a #[header(\"name\")] attribute",
855            ));
856        };
857
858        params.push(HeaderParamMeta {
859            arg_idx,
860            name,
861            required,
862            inner_type,
863        });
864    }
865
866    Ok(params)
867}
868
869/// Registers a channel handler for the relay system.
870///
871/// Channel handlers receive [`RelayEvent`](rapina::relay::RelayEvent) events
872/// when clients subscribe, send messages, or disconnect from matching topics.
873///
874/// The pattern supports exact matches and prefix matches (trailing `*`):
875///
876/// - `"chat:lobby"` — matches only the exact topic `"chat:lobby"`
877/// - `"room:*"` — matches any topic starting with `"room:"`
878///
879/// The first parameter must be `RelayEvent`. Remaining parameters are
880/// extracted via `FromRequestParts` with synthetic request parts (same
881/// extractors as HTTP handlers, minus body extractors).
882///
883/// # Example
884///
885/// ```ignore
886/// use rapina::prelude::*;
887/// use rapina::relay::{Relay, RelayEvent};
888///
889/// #[relay("room:*")]
890/// async fn room(event: RelayEvent, relay: Relay) -> Result<()> {
891///     match &event {
892///         RelayEvent::Join { topic, conn_id } => {
893///             relay.track(topic, *conn_id, serde_json::json!({}));
894///         }
895///         RelayEvent::Message { topic, event: ev, payload, .. } => {
896///             relay.push(topic, ev, payload).await?;
897///         }
898///         RelayEvent::Leave { topic, conn_id } => {
899///             relay.untrack(topic, *conn_id);
900///         }
901///     }
902///     Ok(())
903/// }
904/// ```
905#[proc_macro_attribute]
906pub fn relay(attr: TokenStream, item: TokenStream) -> TokenStream {
907    relay_macro_impl(attr.into(), item.into()).into()
908}
909
910fn relay_macro_impl(
911    attr: proc_macro2::TokenStream,
912    item: proc_macro2::TokenStream,
913) -> proc_macro2::TokenStream {
914    let pattern: LitStr = syn::parse2(attr).expect("expected pattern as string literal");
915    let pattern_str = pattern.value();
916    let func: ItemFn = syn::parse2(item).expect("#[relay] must be applied to an async function");
917
918    let func_name = &func.sig.ident;
919    let func_name_str = func_name.to_string();
920
921    let is_prefix = pattern_str.ends_with('*');
922    let match_prefix_str = if is_prefix {
923        &pattern_str[..pattern_str.len() - 1]
924    } else {
925        &pattern_str
926    };
927
928    let wrapper_name = syn::Ident::new(
929        &format!("__rapina_channel_{}", func_name_str),
930        proc_macro2::Span::call_site(),
931    );
932
933    // First arg is RelayEvent (passed directly). Remaining args are extractors.
934    let args: Vec<_> = func.sig.inputs.iter().collect();
935
936    let mut extractor_extractions = Vec::new();
937    let mut call_args = vec![quote! { __rapina_event }];
938
939    for (i, arg) in args.iter().enumerate() {
940        if i == 0 {
941            // First arg is RelayEvent — passed directly, not extracted
942            continue;
943        }
944        if let FnArg::Typed(pat_type) = arg {
945            if let Pat::Ident(pat_ident) = &*pat_type.pat {
946                let arg_name = &pat_ident.ident;
947                let arg_type = &pat_type.ty;
948
949                extractor_extractions.push(quote! {
950                    let #arg_name = <#arg_type as rapina::extract::FromRequestParts>::from_request_parts(
951                        &__rapina_parts, &__rapina_params, &__rapina_state
952                    ).await?;
953                });
954
955                call_args.push(quote! { #arg_name });
956            }
957        }
958    }
959
960    quote! {
961        #func
962
963        // Generated by #[relay] — not user-facing API
964        #[doc(hidden)]
965        fn #wrapper_name(
966            __rapina_event: rapina::relay::RelayEvent,
967            __rapina_state: std::sync::Arc<rapina::state::AppState>,
968            __rapina_current_user: Option<rapina::auth::CurrentUser>,
969        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = std::result::Result<(), rapina::error::Error>> + Send>> {
970            Box::pin(async move {
971                let (mut __rapina_parts, _) = rapina::http::Request::new(()).into_parts();
972                if let Some(u) = __rapina_current_user {
973                    __rapina_parts.extensions.insert(u);
974                }
975                let __rapina_params = rapina::extract::PathParams::new();
976                #(#extractor_extractions)*
977                #func_name(#(#call_args),*).await
978            })
979        }
980
981        rapina::inventory::submit! {
982            rapina::relay::ChannelDescriptor {
983                pattern: #pattern_str,
984                is_prefix: #is_prefix,
985                match_prefix: #match_prefix_str,
986                handler_name: #func_name_str,
987                handle: #wrapper_name,
988            }
989        }
990    }
991}
992
993/// Marks a static Prometheus collector for auto-discovery.
994///
995/// Annotate a module-level `static` holding a collector and `.discover()`
996/// registers it with the `/metrics` endpoint, so you don't have to thread it
997/// through `add_metric()`. Requires the `metrics` feature plus both
998/// `.enable_metrics()` and `.discover()` on the app builder.
999///
1000/// The collector type must be `Clone` (all built-in prometheus types are;
1001/// clones share the same underlying values). Wrap the collector in
1002/// `std::sync::LazyLock` or `once_cell::sync::Lazy`; no built-in prometheus
1003/// type can be constructed in a const context, so a bare static won't
1004/// compile. `OnceLock`-style cells are not supported, and the static must
1005/// live at module scope, not inside a function body.
1006///
1007/// This is the only Rapina attribute applied to a `static` rather than a
1008/// function.
1009///
1010/// # Example
1011///
1012/// ```ignore
1013/// use std::sync::LazyLock;
1014/// use rapina::metric;
1015/// use rapina::prometheus::IntCounter;
1016///
1017/// #[metric]
1018/// static ORDERS_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
1019///     IntCounter::new("orders_total", "Total orders placed").unwrap()
1020/// });
1021/// ```
1022#[proc_macro_attribute]
1023pub fn metric(attr: TokenStream, item: TokenStream) -> TokenStream {
1024    metric_macro_impl(attr.into(), item.into()).into()
1025}
1026
1027fn metric_macro_impl(
1028    attr: proc_macro2::TokenStream,
1029    item: proc_macro2::TokenStream,
1030) -> proc_macro2::TokenStream {
1031    if !attr.is_empty() {
1032        return syn::Error::new_spanned(attr, "#[metric] does not take arguments")
1033            .to_compile_error();
1034    }
1035    let item = match syn::parse2::<syn::ItemStatic>(item) {
1036        Ok(item) => item,
1037        Err(err) => {
1038            return syn::Error::new(
1039                err.span(),
1040                "#[metric] can only be applied to a `static` item",
1041            )
1042            .to_compile_error();
1043        }
1044    };
1045    if let syn::StaticMutability::Mut(m) = &item.mutability {
1046        return syn::Error::new_spanned(m, "#[metric] cannot be applied to a `static mut`")
1047            .to_compile_error();
1048    }
1049
1050    let ident = &item.ident;
1051    let collector_fn = quote::format_ident!("__rapina_metric_{}", ident);
1052
1053    quote! {
1054        #item
1055
1056        #[doc(hidden)]
1057        #[allow(non_snake_case)]
1058        fn #collector_fn() -> Box<dyn rapina::prometheus::core::Collector> {
1059            Box::new(#ident.clone())
1060        }
1061
1062        rapina::inventory::submit! {
1063            rapina::discovery::MetricDescriptor {
1064                collector: #collector_fn,
1065            }
1066        }
1067    }
1068}
1069
1070/// Defines a background job handler.
1071///
1072/// Annotate an `async fn` to register it as a background job. The first
1073/// argument is always the payload type (must implement `Serialize +
1074/// DeserializeOwned`). Remaining arguments are dependency-injected from
1075/// `AppState` — `State<T>` and `Db` are the supported extractors.
1076///
1077/// Optionally configure the queue and retry limit:
1078///
1079/// ```text
1080/// #[job(queue = "emails", max_retries = 5)]
1081/// ```
1082///
1083/// Defaults: `queue = "default"`, `max_retries = 3`.
1084///
1085/// # What the macro generates
1086///
1087/// Given:
1088///
1089/// ```rust,ignore
1090/// #[job(queue = "emails")]
1091/// async fn send_welcome_email(
1092///     payload: WelcomeEmailPayload,
1093///     mailer: State<Mailer>,
1094/// ) -> JobResult { ... }
1095/// ```
1096///
1097/// The macro generates a helper function with the same name and visibility:
1098///
1099/// ```rust,ignore
1100/// fn send_welcome_email(payload: WelcomeEmailPayload) -> JobRequest {
1101///     JobRequest { job_type: "send_welcome_email", queue: "emails", ... }
1102/// }
1103/// ```
1104///
1105/// The `Jobs` extractor and `enqueue()` API for dispatching jobs from handlers
1106/// are planned for a follow-up release.
1107///
1108/// The handler is also registered via `inventory` for runtime dispatch —
1109/// no manual registration needed.
1110///
1111/// # Feature requirement
1112///
1113/// Requires the `database` feature. The generated types (`JobRequest`,
1114/// `JobDescriptor`) live in `rapina::jobs`, which is gated behind that feature.
1115///
1116/// # DI limitations
1117///
1118/// Only `State<T>` and `Db` work in job handlers. Request-bound extractors
1119/// (`Context`, `Headers`, `Path`, `CurrentUser`) will fail at runtime.
1120#[proc_macro_attribute]
1121pub fn job(attr: TokenStream, item: TokenStream) -> TokenStream {
1122    job_macro_impl(attr.into(), item.into()).into()
1123}
1124
1125struct JobAttr {
1126    queue: String,
1127    max_retries: i32,
1128    retry_policy: String,
1129    retry_delay_secs: f64,
1130}
1131
1132impl Default for JobAttr {
1133    fn default() -> Self {
1134        Self {
1135            queue: "default".to_string(),
1136            max_retries: 3,
1137            retry_policy: "exponential".to_string(),
1138            retry_delay_secs: 1.0,
1139        }
1140    }
1141}
1142
1143impl syn::parse::Parse for JobAttr {
1144    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1145        let mut attr = JobAttr::default();
1146
1147        while !input.is_empty() {
1148            let ident: syn::Ident = input.parse()?;
1149            input.parse::<syn::Token![=]>()?;
1150
1151            if ident == "queue" {
1152                let lit: syn::LitStr = input.parse()?;
1153                let q = lit.value();
1154                if q.is_empty() {
1155                    return Err(syn::Error::new(lit.span(), "queue name must not be empty"));
1156                }
1157                attr.queue = q;
1158            } else if ident == "max_retries" {
1159                let lit: syn::LitInt = input.parse()?;
1160                let val: i32 = lit.base10_parse()?;
1161                if val < 0 {
1162                    return Err(syn::Error::new(lit.span(), "max_retries must be >= 0"));
1163                }
1164                attr.max_retries = val;
1165            } else if ident == "retry_policy" {
1166                let lit: syn::LitStr = input.parse()?;
1167                let val = lit.value();
1168                if !matches!(val.as_str(), "exponential" | "fixed" | "none") {
1169                    return Err(syn::Error::new(
1170                        lit.span(),
1171                        "retry_policy must be \"exponential\", \"fixed\", or \"none\"",
1172                    ));
1173                }
1174                attr.retry_policy = val;
1175            } else if ident == "retry_delay_secs" {
1176                let val: f64 = if input.peek(syn::LitFloat) {
1177                    let lit: syn::LitFloat = input.parse()?;
1178                    lit.base10_parse()?
1179                } else {
1180                    let lit: syn::LitInt = input.parse()?;
1181                    let v: u64 = lit.base10_parse()?;
1182                    v as f64
1183                };
1184                if val < 0.0 {
1185                    return Err(syn::Error::new(
1186                        proc_macro2::Span::call_site(),
1187                        "retry_delay_secs must be >= 0",
1188                    ));
1189                }
1190                attr.retry_delay_secs = val;
1191            } else if ident == "timeout" {
1192                // Consume the value so the error points at the attribute name, not EOF.
1193                let _: syn::LitStr = input.parse()?;
1194                return Err(syn::Error::new(
1195                    ident.span(),
1196                    "#[job(timeout = ...)] is not yet supported — coming in a future release",
1197                ));
1198            } else {
1199                return Err(syn::Error::new(
1200                    ident.span(),
1201                    format!(
1202                        "unknown #[job] attribute `{ident}` — supported: `queue`, `max_retries`, `retry_policy`, `retry_delay_secs`"
1203                    ),
1204                ));
1205            }
1206
1207            if input.peek(syn::Token![,]) {
1208                input.parse::<syn::Token![,]>()?;
1209            }
1210        }
1211
1212        Ok(attr)
1213    }
1214}
1215
1216fn job_macro_impl(
1217    attr: proc_macro2::TokenStream,
1218    item: proc_macro2::TokenStream,
1219) -> proc_macro2::TokenStream {
1220    let job_attr: JobAttr = match syn::parse2(attr) {
1221        Ok(a) => a,
1222        Err(e) => return e.to_compile_error(),
1223    };
1224
1225    let func: ItemFn = match syn::parse2(item) {
1226        Ok(f) => f,
1227        Err(e) => return e.to_compile_error(),
1228    };
1229
1230    // Must be async — the handle wrapper calls the impl with .await.
1231    if func.sig.asyncness.is_none() {
1232        return syn::Error::new(
1233            func.sig.fn_token.span,
1234            "#[job] must be applied to an async function",
1235        )
1236        .to_compile_error();
1237    }
1238
1239    // Generic parameters can't be monomorphized into a fn pointer for inventory.
1240    if !func.sig.generics.params.is_empty() {
1241        return syn::Error::new(
1242            func.sig.generics.params.first().unwrap().span(),
1243            "#[job] does not support generic type parameters — the payload type must be concrete",
1244        )
1245        .to_compile_error();
1246    }
1247
1248    let func_name = &func.sig.ident;
1249    let func_name_str = func_name.to_string();
1250    let func_vis = &func.vis;
1251
1252    let impl_fn_name = syn::Ident::new(
1253        &format!("__rapina_job_impl_{}", func_name_str),
1254        proc_macro2::Span::call_site(),
1255    );
1256    let handle_fn_name = syn::Ident::new(
1257        &format!("__rapina_job_handle_{}", func_name_str),
1258        proc_macro2::Span::call_site(),
1259    );
1260
1261    let queue_str = &job_attr.queue;
1262    let max_retries = job_attr.max_retries;
1263    let retry_policy_str = &job_attr.retry_policy;
1264    let retry_delay_secs = job_attr.retry_delay_secs;
1265
1266    let args: Vec<_> = func.sig.inputs.iter().collect();
1267
1268    if args.is_empty() {
1269        return syn::Error::new(
1270            func.sig.ident.span(),
1271            "#[job] requires at least one argument (the payload type)",
1272        )
1273        .to_compile_error();
1274    }
1275
1276    // First arg is the payload — extract its type for the helper signature and
1277    // for the serde_json::from_value call in the handle wrapper.
1278    let payload_type = match &args[0] {
1279        FnArg::Typed(pat_type) => &pat_type.ty,
1280        FnArg::Receiver(r) => {
1281            return syn::Error::new(
1282                r.self_token.span,
1283                "#[job] cannot be applied to a method — use a free function",
1284            )
1285            .to_compile_error();
1286        }
1287    };
1288
1289    // Remaining args are DI extractors (State<T>, Db, etc.).
1290    let mut extractor_extractions = Vec::new();
1291    let mut di_call_args = Vec::new();
1292
1293    for (i, arg) in args[1..].iter().enumerate() {
1294        if let FnArg::Typed(pat_type) = arg {
1295            let arg_type = &pat_type.ty;
1296            let tmp = syn::Ident::new(
1297                &format!("__rapina_di_{}", i),
1298                proc_macro2::Span::call_site(),
1299            );
1300            extractor_extractions.push(quote! {
1301                let #tmp = <#arg_type as rapina::extract::FromRequestParts>::from_request_parts(
1302                    &__rapina_parts, &__rapina_params, &__rapina_state
1303                ).await?;
1304            });
1305            di_call_args.push(quote! { #tmp });
1306        }
1307    }
1308
1309    let impl_inputs = &func.sig.inputs;
1310    let impl_output = &func.sig.output;
1311    let func_block = &func.block;
1312    let func_attrs = &func.attrs;
1313
1314    quote! {
1315        // Original handler body, renamed to an internal function. Only called
1316        // by the handle wrapper below — never exposed directly.
1317        #(#func_attrs)*
1318        #[doc(hidden)]
1319        async fn #impl_fn_name(#impl_inputs) #impl_output
1320        #func_block
1321
1322        // DI wrapper registered in inventory. Deserializes the JSON payload,
1323        // creates synthetic request parts for extractor compatibility, injects
1324        // dependencies from AppState, then calls the impl function.
1325        //
1326        // Only State<T> and Db work here — they source data from AppState
1327        // directly and ignore the synthetic parts.
1328        #[doc(hidden)]
1329        fn #handle_fn_name(
1330            __rapina_payload_raw: rapina::serde_json::Value,
1331            __rapina_state: std::sync::Arc<rapina::state::AppState>,
1332        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = rapina::jobs::JobResult> + Send>>
1333        {
1334            Box::pin(async move {
1335                let __rapina_payload_typed: #payload_type =
1336                    match rapina::serde_json::from_value(__rapina_payload_raw) {
1337                        Ok(v) => v,
1338                        Err(e) => {
1339                            return Err(rapina::error::Error::internal(format!(
1340                                "failed to deserialize job payload for '{}': {e}",
1341                                #func_name_str
1342                            )));
1343                        }
1344                    };
1345                let (__rapina_parts, _) = rapina::http::Request::new(()).into_parts();
1346                let __rapina_params = rapina::extract::PathParams::new();
1347                #(#extractor_extractions)*
1348                #impl_fn_name(__rapina_payload_typed, #(#di_call_args),*).await
1349            })
1350        }
1351
1352        // Helper function with the same name and visibility as the original.
1353        // Call this to build a JobRequest for jobs.enqueue().
1354        #func_vis fn #func_name(payload: #payload_type) -> rapina::jobs::JobRequest {
1355            rapina::jobs::JobRequest {
1356                job_type: #func_name_str,
1357                payload: rapina::serde_json::to_value(payload).expect(
1358                    "job payload serialization failed — ensure all fields are JSON-compatible",
1359                ),
1360                queue: #queue_str,
1361                max_retries: #max_retries,
1362            }
1363        }
1364
1365        rapina::inventory::submit! {
1366            rapina::jobs::JobDescriptor {
1367                job_type: #func_name_str,
1368                handle: #handle_fn_name,
1369                retry_policy: #retry_policy_str,
1370                retry_delay_secs: #retry_delay_secs,
1371            }
1372        }
1373    }
1374}
1375
1376fn route_macro(method: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
1377    route_macro_core(method, attr.into(), item.into()).into()
1378}
1379
1380/// Derive macro for type-safe configuration
1381///
1382/// Generates a `from_env()` method that loads configuration from environment variables.
1383#[proc_macro_derive(Config, attributes(env, default))]
1384pub fn derive_config(input: TokenStream) -> TokenStream {
1385    derive_config_impl(input.into()).into()
1386}
1387
1388/// Define database entities with Prisma-like syntax.
1389///
1390/// This macro generates SeaORM entity definitions from a declarative syntax
1391/// where types indicate relationships. Each entity automatically gets `id`,
1392/// `created_at`, and `updated_at` fields.
1393///
1394/// # Syntax
1395///
1396/// ```ignore
1397/// rapina::schema! {
1398///     User {
1399///         email: String,
1400///         name: String,
1401///         posts: Vec<Post>,        // has_many relationship
1402///     }
1403///
1404///     Post {
1405///         title: String,
1406///         content: Text,           // TEXT column type
1407///         author: User,            // belongs_to -> generates author_id
1408///         comments: Vec<Comment>,
1409///     }
1410///
1411///     Comment {
1412///         content: Text,
1413///         post: Post,
1414///         author: Option<User>,    // optional belongs_to
1415///     }
1416/// }
1417/// ```
1418///
1419/// # Generated Code
1420///
1421/// For each entity, the macro generates a SeaORM module with:
1422/// - `Model` struct with auto `id`, `created_at`, `updated_at`
1423/// - `Relation` enum with proper SeaORM attributes
1424/// - `Related<T>` trait implementations
1425/// - `ActiveModelBehavior` implementation
1426///
1427/// # Supported Types
1428///
1429/// | Schema Type | Rust Type | Notes |
1430/// |-------------|-----------|-------|
1431/// | `String` | `String` | Default varchar |
1432/// | `Text` | `String` | TEXT column |
1433/// | `i32` | `i32` | |
1434/// | `i64` | `i64` | |
1435/// | `f32` | `f32` | |
1436/// | `f64` | `f64` | |
1437/// | `bool` | `bool` | |
1438/// | `Uuid` | `Uuid` | |
1439/// | `DateTime` | `DateTimeUtc` | |
1440/// | `Date` | `Date` | |
1441/// | `Decimal` | `Decimal` | |
1442/// | `Json` | `Json` | |
1443/// | `Option<T>` | `Option<T>` | Nullable |
1444/// | `Vec<Entity>` | - | has_many relationship |
1445/// | `Entity` | - | belongs_to (generates FK) |
1446#[proc_macro]
1447pub fn schema(input: TokenStream) -> TokenStream {
1448    schema::schema_impl(input.into()).into()
1449}
1450
1451fn derive_config_impl(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
1452    let input: syn::DeriveInput = syn::parse2(input).expect("expected struct");
1453    let name = &input.ident;
1454
1455    let fields = match &input.data {
1456        syn::Data::Struct(data) => match &data.fields {
1457            syn::Fields::Named(fields) => &fields.named,
1458            _ => panic!("Config derive only supports structs with named fields"),
1459        },
1460        _ => panic!("Config derive only supports structs"),
1461    };
1462
1463    let mut field_inits = Vec::new();
1464    let mut missing_checks = Vec::new();
1465
1466    for field in fields {
1467        let field_name = field.ident.as_ref().unwrap();
1468        let field_type = &field.ty;
1469
1470        // Find #[env = "VAR_NAME"] attribute
1471        let env_var = field
1472            .attrs
1473            .iter()
1474            .find_map(|attr| {
1475                if attr.path().is_ident("env")
1476                    && let syn::Meta::NameValue(nv) = &attr.meta
1477                    && let syn::Expr::Lit(expr_lit) = &nv.value
1478                    && let syn::Lit::Str(lit_str) = &expr_lit.lit
1479                {
1480                    return Some(lit_str.value());
1481                }
1482                None
1483            })
1484            .unwrap_or_else(|| field_name.to_string().to_uppercase());
1485
1486        // Find #[default = "value"] attribute
1487        let default_value = field.attrs.iter().find_map(|attr| {
1488            if attr.path().is_ident("default")
1489                && let syn::Meta::NameValue(nv) = &attr.meta
1490                && let syn::Expr::Lit(expr_lit) = &nv.value
1491                && let syn::Lit::Str(lit_str) = &expr_lit.lit
1492            {
1493                return Some(lit_str.value());
1494            }
1495            None
1496        });
1497
1498        let env_var_lit = syn::LitStr::new(&env_var, proc_macro2::Span::call_site());
1499
1500        if let Some(default) = default_value {
1501            let default_lit = syn::LitStr::new(&default, proc_macro2::Span::call_site());
1502            field_inits.push(quote! {
1503                #field_name: rapina::config::get_env_or(#env_var_lit, #default_lit).parse().unwrap_or_else(|_| #default_lit.parse().unwrap())
1504            });
1505        } else {
1506            field_inits.push(quote! {
1507                #field_name: rapina::config::get_env_parsed::<#field_type>(#env_var_lit)?
1508            });
1509            missing_checks.push(quote! {
1510                if std::env::var(#env_var_lit).is_err() {
1511                    missing.push(#env_var_lit);
1512                }
1513            });
1514        }
1515    }
1516
1517    quote! {
1518        impl #name {
1519            pub fn from_env() -> std::result::Result<Self, rapina::config::ConfigError> {
1520                let mut missing: Vec<&str> = Vec::new();
1521                #(#missing_checks)*
1522
1523                if !missing.is_empty() {
1524                    return Err(rapina::config::ConfigError::MissingMultiple(
1525                        missing.into_iter().map(String::from).collect()
1526                    ));
1527                }
1528
1529                Ok(Self {
1530                    #(#field_inits),*
1531                })
1532            }
1533        }
1534    }
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539    use super::{
1540        job_macro_impl, join_paths, metric_macro_impl, relay_macro_impl, route_macro_core,
1541    };
1542    use quote::quote;
1543
1544    #[test]
1545    fn test_generates_struct_with_handler_impl() {
1546        let path = quote!("/");
1547        let input = quote! {
1548            async fn hello() -> &'static str {
1549                "Hello, Rapina!"
1550            }
1551        };
1552
1553        let output = route_macro_core("GET", path, input);
1554        let output_str = output.to_string();
1555
1556        // Check struct is generated
1557        assert!(output_str.contains("struct hello"));
1558        // Check Handler impl is generated
1559        assert!(output_str.contains("impl rapina :: handler :: Handler for hello"));
1560        // Check NAME constant
1561        assert!(output_str.contains("const NAME"));
1562        assert!(output_str.contains("\"hello\""));
1563    }
1564
1565    #[test]
1566    fn test_generates_handler_with_extractors() {
1567        let path = quote!("/users/:id");
1568        let input = quote! {
1569            async fn get_user(id: rapina::extract::Path<u64>) -> String {
1570                format!("{}", id.into_inner())
1571            }
1572        };
1573
1574        let output = route_macro_core("GET", path, input);
1575        let output_str = output.to_string();
1576
1577        assert!(output_str.contains("struct get_user"));
1578        // Single arg is last arg — uses FromRequest (blanket impl handles parts-only)
1579        assert!(output_str.contains("FromRequest"));
1580        // Single arg should NOT destructure request into parts
1581        assert!(!output_str.contains("into_parts"));
1582    }
1583
1584    #[test]
1585    fn test_function_with_multiple_extractors() {
1586        let path = quote!("/users");
1587        let input = quote! {
1588            async fn create_user(
1589                id: rapina::extract::Path<u64>,
1590                body: rapina::extract::Json<String>
1591            ) -> String {
1592                "created".to_string()
1593            }
1594        };
1595
1596        let output = route_macro_core("POST", path, input);
1597        let output_str = output.to_string();
1598
1599        // Check struct is generated
1600        assert!(output_str.contains("struct create_user"));
1601        // Check both extractors are handled
1602        assert!(output_str.contains("FromRequestParts"));
1603        assert!(output_str.contains("FromRequest"));
1604    }
1605
1606    #[test]
1607    fn test_two_body_extractors_no_macro_panic() {
1608        // With positional convention, the macro does NOT panic for multiple body consumers.
1609        // Instead, it generates code where the first Json is bounded by FromRequestParts
1610        // (which it doesn't implement), so the compiler catches it at type-check time.
1611        let path = quote!("/users");
1612        let input = quote! {
1613            async fn handler(
1614                body1: rapina::extract::Json<String>,
1615                body2: rapina::extract::Json<String>
1616            ) -> String {
1617                "ok".to_string()
1618            }
1619        };
1620
1621        // Should NOT panic — macro expansion succeeds, compiler catches the error later
1622        let output = route_macro_core("POST", path, input);
1623        let output_str = output.to_string();
1624
1625        // First arg gets FromRequestParts (will fail at compile time since Json doesn't impl it)
1626        assert!(output_str.contains("FromRequestParts"));
1627        // Last arg gets FromRequest
1628        assert!(output_str.contains("FromRequest"));
1629    }
1630
1631    #[test]
1632    fn test_custom_type_name_not_misclassified() {
1633        // UserPathInfo contains "Path" but should NOT be routed to FromRequestParts
1634        // Positional convention: single (last) arg always uses FromRequest
1635        let path = quote!("/users");
1636        let input = quote! {
1637            async fn handler(info: UserPathInfo) -> String {
1638                "ok".to_string()
1639            }
1640        };
1641
1642        let output = route_macro_core("POST", path, input);
1643        let output_str = output.to_string();
1644
1645        assert!(output_str.contains("FromRequest"));
1646        assert!(!output_str.contains("FromRequestParts"));
1647    }
1648
1649    #[test]
1650    fn test_multiple_parts_only_extractors_positional() {
1651        // All parts-only extractors: first N-1 use FromRequestParts, last uses FromRequest
1652        let path = quote!("/users/:id");
1653        let input = quote! {
1654            async fn handler(
1655                id: rapina::extract::Path<u64>,
1656                query: rapina::extract::Query<Params>,
1657                headers: rapina::extract::Headers,
1658            ) -> String {
1659                "ok".to_string()
1660            }
1661        };
1662
1663        let output = route_macro_core("GET", path, input);
1664        let output_str = output.to_string();
1665
1666        // First two args use FromRequestParts
1667        assert!(output_str.contains("FromRequestParts"));
1668        // Last arg uses FromRequest (via blanket impl at runtime)
1669        assert!(output_str.contains("FromRequest"));
1670        // Request is destructured for multi-arg case
1671        assert!(output_str.contains("into_parts"));
1672        // Request is reassembled for last arg
1673        assert!(output_str.contains("from_parts"));
1674    }
1675
1676    #[test]
1677    #[should_panic(expected = "expected function")]
1678    fn test_invalid_input_panics() {
1679        let path = quote!("/");
1680        let invalid_input = quote! { not_a_function };
1681
1682        route_macro_core("GET", path, invalid_input);
1683    }
1684
1685    #[test]
1686    fn test_json_return_type_generates_response_schema() {
1687        let path = quote!("/users");
1688        let input = quote! {
1689            async fn get_user() -> Json<UserResponse> {
1690                Json(UserResponse { id: 1 })
1691            }
1692        };
1693
1694        let output = route_macro_core("GET", path, input);
1695        let output_str = output.to_string();
1696
1697        // Check response_schema method is generated with openapi_schema_for
1698        assert!(output_str.contains("fn response_schema"));
1699        assert!(output_str.contains("rapina :: openapi_schema_for"));
1700        assert!(output_str.contains("UserResponse"));
1701    }
1702
1703    #[test]
1704    fn test_result_json_return_type_generates_response_schema() {
1705        let path = quote!("/users");
1706        let input = quote! {
1707            async fn get_user() -> Result<Json<UserResponse>> {
1708                Ok(Json(UserResponse { id: 1 }))
1709            }
1710        };
1711
1712        let output = route_macro_core("GET", path, input);
1713        let output_str = output.to_string();
1714
1715        assert!(output_str.contains("fn response_schema"));
1716        assert!(output_str.contains("rapina :: openapi_schema_for"));
1717        assert!(output_str.contains("UserResponse"));
1718    }
1719
1720    #[test]
1721    fn test_errors_attr_generates_error_responses() {
1722        let path = quote!("/users");
1723        let input = quote! {
1724            #[errors(UserError)]
1725            async fn get_user() -> Result<Json<UserResponse>> {
1726                Ok(Json(UserResponse { id: 1 }))
1727            }
1728        };
1729
1730        let output = route_macro_core("GET", path, input);
1731        let output_str = output.to_string();
1732
1733        assert!(output_str.contains("fn error_responses"));
1734        assert!(output_str.contains("DocumentedError"));
1735        assert!(output_str.contains("UserError"));
1736    }
1737
1738    #[test]
1739    fn test_json_body_generates_request_schema_and_content_type() {
1740        let path = quote!("/users");
1741        let input = quote! {
1742            async fn create_user(body: Json<CreateUserRequest>) -> Json<UserResponse> {
1743                Json(UserResponse { id: 1 })
1744            }
1745        };
1746
1747        let output = route_macro_core("POST", path, input);
1748        let output_str = output.to_string();
1749
1750        // Check request_schema method is generated
1751        assert!(output_str.contains("fn request_schema"));
1752        assert!(output_str.contains("CreateUserRequest"));
1753        // Check request_content_type method is generated with JSON content type
1754        assert!(output_str.contains("fn request_content_type"));
1755        assert!(output_str.contains("application/json"));
1756    }
1757
1758    #[test]
1759    fn test_form_body_generates_request_schema_and_content_type() {
1760        let path = quote!("/users");
1761        let input = quote! {
1762            async fn create_user(body: Form<CreateUserForm>) -> Json<UserResponse> {
1763                Json(UserResponse { id: 1 })
1764            }
1765        };
1766
1767        let output = route_macro_core("POST", path, input);
1768        let output_str = output.to_string();
1769
1770        assert!(output_str.contains("fn request_schema"));
1771        assert!(output_str.contains("CreateUserForm"));
1772        // Check request_content_type method is generated with form content type
1773        assert!(output_str.contains("fn request_content_type"));
1774        assert!(output_str.contains("application/x-www-form-urlencoded"));
1775    }
1776
1777    #[test]
1778    fn test_validated_json_generates_request_schema_and_content_type() {
1779        let path = quote!("/users");
1780        let input = quote! {
1781            async fn create_user(body: Validated<Json<CreateUserRequest>>) -> Json<UserResponse> {
1782                Json(UserResponse { id: 1 })
1783            }
1784        };
1785
1786        let output = route_macro_core("POST", path, input);
1787        let output_str = output.to_string();
1788
1789        // Should extract CreateUserRequest from Validated<Json<CreateUserRequest>>
1790        assert!(output_str.contains("fn request_schema"));
1791        assert!(output_str.contains("CreateUserRequest"));
1792        // Should inherit JSON content type from inner Json extractor
1793        assert!(output_str.contains("fn request_content_type"));
1794        assert!(output_str.contains("application/json"));
1795    }
1796
1797    #[test]
1798    fn test_validated_form_generates_request_schema_and_content_type() {
1799        let path = quote!("/login");
1800        let input = quote! {
1801            async fn login(body: Validated<Form<LoginForm>>) -> Json<TokenResponse> {
1802                Json(TokenResponse { token: "abc".into() })
1803            }
1804        };
1805
1806        let output = route_macro_core("POST", path, input);
1807        let output_str = output.to_string();
1808
1809        // Should extract LoginForm from Validated<Form<LoginForm>>
1810        assert!(output_str.contains("fn request_schema"));
1811        assert!(output_str.contains("LoginForm"));
1812        // Should inherit form content type from inner Form extractor
1813        assert!(output_str.contains("fn request_content_type"));
1814        assert!(output_str.contains("application/x-www-form-urlencoded"));
1815    }
1816
1817    #[test]
1818    fn test_option_json_generates_optional_request_body() {
1819        let path = quote!("/users");
1820        let input = quote! {
1821            async fn update_user(body: Option<Json<UpdateUserRequest>>) -> Json<UserResponse> {
1822                Json(UserResponse { id: 1 })
1823            }
1824        };
1825
1826        let output = route_macro_core("PATCH", path, input);
1827        let output_str = output.to_string();
1828
1829        // Should extract UpdateUserRequest from Option<Json<UpdateUserRequest>>
1830        assert!(output_str.contains("fn request_schema"));
1831        assert!(output_str.contains("UpdateUserRequest"));
1832        // Should have JSON content type
1833        assert!(output_str.contains("fn request_content_type"));
1834        assert!(output_str.contains("application/json"));
1835        // Should have request_body_required returning false
1836        assert!(output_str.contains("fn request_body_required"));
1837        assert!(output_str.contains("Some (false)"));
1838    }
1839
1840    #[test]
1841    fn test_option_form_generates_optional_request_body() {
1842        let path = quote!("/login");
1843        let input = quote! {
1844            async fn login(body: Option<Form<LoginForm>>) -> Json<TokenResponse> {
1845                Json(TokenResponse { token: "abc".into() })
1846            }
1847        };
1848
1849        let output = route_macro_core("POST", path, input);
1850        let output_str = output.to_string();
1851
1852        // Should extract LoginForm from Option<Form<LoginForm>>
1853        assert!(output_str.contains("fn request_schema"));
1854        assert!(output_str.contains("LoginForm"));
1855        // Should have form content type
1856        assert!(output_str.contains("fn request_content_type"));
1857        assert!(output_str.contains("application/x-www-form-urlencoded"));
1858        // Should have request_body_required returning false
1859        assert!(output_str.contains("fn request_body_required"));
1860        assert!(output_str.contains("Some (false)"));
1861    }
1862
1863    #[test]
1864    fn test_get_with_json_body_no_request_schema() {
1865        // GET handlers should not generate requestBody even if they have Json<T> parameter
1866        let path = quote!("/users");
1867        let input = quote! {
1868            async fn list_users(body: Json<FilterRequest>) -> Json<Vec<UserResponse>> {
1869                Json(vec![])
1870            }
1871        };
1872
1873        let output = route_macro_core("GET", path, input);
1874        let output_str = output.to_string();
1875
1876        // Should NOT generate request_schema for GET method
1877        assert!(!output_str.contains("fn request_schema"));
1878        assert!(!output_str.contains("fn request_content_type"));
1879        assert!(!output_str.contains("fn request_body_required"));
1880    }
1881
1882    #[test]
1883    fn test_delete_with_json_body_no_request_schema() {
1884        // DELETE handlers should not generate requestBody even if they have Json<T> parameter
1885        let path = quote!("/users/:id");
1886        let input = quote! {
1887            async fn delete_user(body: Json<DeleteRequest>) -> StatusCode {
1888                StatusCode::NO_CONTENT
1889            }
1890        };
1891
1892        let output = route_macro_core("DELETE", path, input);
1893        let output_str = output.to_string();
1894
1895        // Should NOT generate request_schema for DELETE method
1896        assert!(!output_str.contains("fn request_schema"));
1897        assert!(!output_str.contains("fn request_content_type"));
1898        assert!(!output_str.contains("fn request_body_required"));
1899    }
1900
1901    #[test]
1902    fn test_no_body_no_request_schema_or_content_type() {
1903        let path = quote!("/users");
1904        let input = quote! {
1905            async fn list_users() -> Json<Vec<UserResponse>> {
1906                Json(vec![])
1907            }
1908        };
1909
1910        let output = route_macro_core("GET", path, input);
1911        let output_str = output.to_string();
1912
1913        // Should NOT generate request_schema or request_content_type for handlers without body
1914        assert!(!output_str.contains("fn request_schema"));
1915        assert!(!output_str.contains("fn request_content_type"));
1916    }
1917
1918    #[test]
1919    fn test_non_json_return_type_no_response_schema() {
1920        let path = quote!("/health");
1921        let input = quote! {
1922            async fn health() -> &'static str {
1923                "ok"
1924            }
1925        };
1926
1927        let output = route_macro_core("GET", path, input);
1928        let output_str = output.to_string();
1929
1930        // Check response_schema method is NOT generated for non-Json types
1931        assert!(!output_str.contains("fn response_schema"));
1932        assert!(!output_str.contains("openapi_schema_for"));
1933    }
1934
1935    #[test]
1936    fn test_user_state_variable_not_shadowed() {
1937        // Regression test for issue #134 - user naming their extractor 'state'
1938        // should not conflict with internal macro variables
1939        let path = quote!("/users");
1940        let input = quote! {
1941            async fn list_users(state: rapina::extract::State<MyState>) -> String {
1942                "ok".to_string()
1943            }
1944        };
1945
1946        let output = route_macro_core("GET", path, input);
1947        let output_str = output.to_string();
1948
1949        // Internal variables should use __rapina_ prefix
1950        assert!(output_str.contains("__rapina_state"));
1951        assert!(output_str.contains("__rapina_params"));
1952        // User's variable 'state' should still be extracted
1953        assert!(output_str.contains("let state ="));
1954    }
1955
1956    #[test]
1957    fn test_no_closure_wrapper_for_type_inference() {
1958        // Regression test for issue #134 - Result type inference should work
1959        let path = quote!("/users");
1960        let input = quote! {
1961            async fn get_user() -> Result<String, Error> {
1962                Ok("user".to_string())
1963            }
1964        };
1965
1966        let output = route_macro_core("GET", path, input);
1967        let output_str = output.to_string();
1968
1969        // Should NOT use closure wrapper (|| async ...)
1970        assert!(!output_str.contains("|| async"));
1971        // Should use typed result with async block (: ReturnType = (async ...).await)
1972        assert!(output_str.contains("__rapina_result"));
1973        assert!(output_str.contains("Result < String , Error >"));
1974    }
1975
1976    #[test]
1977    fn test_emits_route_descriptor() {
1978        let path = quote!("/users");
1979        let input = quote! {
1980            async fn list_users() -> &'static str {
1981                "users"
1982            }
1983        };
1984
1985        let output = route_macro_core("GET", path, input);
1986        let output_str = output.to_string();
1987
1988        assert!(output_str.contains("inventory :: submit !"));
1989        assert!(output_str.contains("RouteDescriptor"));
1990        assert!(output_str.contains("method : \"GET\""));
1991        assert!(output_str.contains("path : \"/users\""));
1992        assert!(output_str.contains("handler_name : \"list_users\""));
1993        assert!(output_str.contains("is_public : false"));
1994        assert!(output_str.contains("__rapina_register_list_users"));
1995    }
1996
1997    #[test]
1998    fn test_emits_route_descriptor_with_method() {
1999        let path = quote!("/users");
2000        let input = quote! {
2001            async fn create_user() -> &'static str {
2002                "created"
2003            }
2004        };
2005
2006        let output = route_macro_core("POST", path, input);
2007        let output_str = output.to_string();
2008
2009        assert!(output_str.contains("method : \"POST\""));
2010        assert!(output_str.contains("__rapina_router . post"));
2011    }
2012
2013    #[test]
2014    fn test_public_attr_below_route_sets_is_public() {
2015        let path = quote!("/health");
2016        let input = quote! {
2017            #[public]
2018            async fn health() -> &'static str {
2019                "ok"
2020            }
2021        };
2022
2023        let output = route_macro_core("GET", path, input);
2024        let output_str = output.to_string();
2025
2026        assert!(output_str.contains("is_public : true"));
2027    }
2028
2029    #[test]
2030    fn test_cache_attr_injects_ttl_header() {
2031        let path = quote!("/products");
2032        let input = quote! {
2033            #[cache(ttl = 60)]
2034            async fn list_products() -> &'static str {
2035                "products"
2036            }
2037        };
2038
2039        let output = route_macro_core("GET", path, input);
2040        let output_str = output.to_string();
2041
2042        assert!(output_str.contains("x-rapina-cache-ttl"));
2043        assert!(output_str.contains("60"));
2044    }
2045
2046    #[test]
2047    fn test_relay_macro_generates_wrapper_and_inventory() {
2048        let attr = quote!("room:*");
2049        let input = quote! {
2050            async fn room(event: rapina::relay::RelayEvent, relay: rapina::relay::Relay) -> Result<(), rapina::error::Error> {
2051                Ok(())
2052            }
2053        };
2054
2055        let output = relay_macro_impl(attr, input);
2056        let output_str = output.to_string();
2057
2058        // Original function is preserved
2059        assert!(output_str.contains("async fn room"));
2060        // Wrapper function is generated
2061        assert!(output_str.contains("__rapina_channel_room"));
2062        // Inventory submission
2063        assert!(output_str.contains("inventory :: submit !"));
2064        assert!(output_str.contains("ChannelDescriptor"));
2065        assert!(output_str.contains("pattern : \"room:*\""));
2066        assert!(output_str.contains("is_prefix : true"));
2067        assert!(output_str.contains("match_prefix : \"room:\""));
2068        assert!(output_str.contains("handler_name : \"room\""));
2069    }
2070
2071    #[test]
2072    fn test_relay_macro_exact_match() {
2073        let attr = quote!("chat:lobby");
2074        let input = quote! {
2075            async fn lobby(event: rapina::relay::RelayEvent) -> Result<(), rapina::error::Error> {
2076                Ok(())
2077            }
2078        };
2079
2080        let output = relay_macro_impl(attr, input);
2081        let output_str = output.to_string();
2082
2083        assert!(output_str.contains("is_prefix : false"));
2084        assert!(output_str.contains("match_prefix : \"chat:lobby\""));
2085    }
2086
2087    #[test]
2088    fn test_metric_macro_generates_collector_fn_and_inventory() {
2089        let input = quote! {
2090            static ORDERS_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
2091                IntCounter::new("orders_total", "Total orders placed").unwrap()
2092            });
2093        };
2094
2095        let output = metric_macro_impl(quote!(), input);
2096        let output_str = output.to_string();
2097
2098        assert!(output_str.contains("static ORDERS_TOTAL"));
2099        assert!(output_str.contains("__rapina_metric_ORDERS_TOTAL"));
2100        assert!(output_str.contains("inventory :: submit !"));
2101        assert!(output_str.contains("MetricDescriptor"));
2102    }
2103
2104    #[test]
2105    fn test_metric_macro_rejects_args() {
2106        let input = quote! {
2107            static ORDERS_TOTAL: LazyLock<IntCounter> = LazyLock::new(make_counter);
2108        };
2109
2110        let output_str = metric_macro_impl(quote!(name = "orders"), input).to_string();
2111
2112        assert!(output_str.contains("compile_error !"));
2113        assert!(output_str.contains("does not take arguments"));
2114    }
2115
2116    #[test]
2117    fn test_metric_macro_rejects_fn() {
2118        let input = quote! {
2119            fn not_a_static() {}
2120        };
2121
2122        let output_str = metric_macro_impl(quote!(), input).to_string();
2123
2124        assert!(output_str.contains("compile_error !"));
2125        assert!(output_str.contains("can only be applied to a `static` item"));
2126    }
2127
2128    #[test]
2129    fn test_metric_macro_rejects_static_mut() {
2130        let input = quote! {
2131            static mut ORDERS_TOTAL: IntCounter = make_counter();
2132        };
2133
2134        let output_str = metric_macro_impl(quote!(), input).to_string();
2135
2136        assert!(output_str.contains("compile_error !"));
2137        assert!(output_str.contains("cannot be applied to a `static mut`"));
2138    }
2139
2140    #[test]
2141    fn test_relay_macro_extracts_additional_params() {
2142        let attr = quote!("room:*");
2143        let input = quote! {
2144            async fn room(
2145                event: rapina::relay::RelayEvent,
2146                relay: rapina::relay::Relay,
2147                log: rapina::extract::State<TestLog>,
2148            ) -> Result<(), rapina::error::Error> {
2149                Ok(())
2150            }
2151        };
2152
2153        let output = relay_macro_impl(attr, input);
2154        let output_str = output.to_string();
2155
2156        // Both extractors should use FromRequestParts
2157        assert!(output_str.contains("let relay ="));
2158        assert!(output_str.contains("let log ="));
2159        assert!(output_str.contains("FromRequestParts"));
2160    }
2161
2162    #[test]
2163    fn test_no_cache_attr_no_ttl_header() {
2164        let path = quote!("/products");
2165        let input = quote! {
2166            async fn list_products() -> &'static str {
2167                "products"
2168            }
2169        };
2170
2171        let output = route_macro_core("GET", path, input);
2172        let output_str = output.to_string();
2173
2174        assert!(!output_str.contains("x-rapina-cache-ttl"));
2175    }
2176
2177    #[test]
2178    fn test_cache_attr_with_extractors() {
2179        let path = quote!("/users/:id");
2180        let input = quote! {
2181            #[cache(ttl = 120)]
2182            async fn get_user(id: rapina::extract::Path<u64>) -> String {
2183                format!("{}", id.into_inner())
2184            }
2185        };
2186
2187        let output = route_macro_core("GET", path, input);
2188        let output_str = output.to_string();
2189
2190        assert!(output_str.contains("x-rapina-cache-ttl"));
2191        assert!(output_str.contains("120"));
2192        // Single arg uses FromRequest (positional convention)
2193        assert!(output_str.contains("FromRequest"));
2194    }
2195
2196    #[test]
2197    fn test_group_param_joins_path() {
2198        let attr = quote!("/users", group = "/api");
2199        let input = quote! {
2200            async fn list_users() -> &'static str {
2201                "users"
2202            }
2203        };
2204
2205        let output = route_macro_core("GET", attr, input);
2206        let output_str = output.to_string();
2207
2208        assert!(output_str.contains("path : \"/api/users\""));
2209        assert!(output_str.contains("__rapina_router . get (\"/api/users\""));
2210    }
2211
2212    #[test]
2213    fn test_group_param_with_nested_prefix() {
2214        let attr = quote!("/items", group = "/api/v1");
2215        let input = quote! {
2216            async fn list_items() -> &'static str {
2217                "items"
2218            }
2219        };
2220
2221        let output = route_macro_core("GET", attr, input);
2222        let output_str = output.to_string();
2223
2224        assert!(output_str.contains("path : \"/api/v1/items\""));
2225    }
2226
2227    #[test]
2228    fn test_without_group_param_backward_compatible() {
2229        let attr = quote!("/users");
2230        let input = quote! {
2231            async fn list_users() -> &'static str {
2232                "users"
2233            }
2234        };
2235
2236        let output = route_macro_core("GET", attr, input);
2237        let output_str = output.to_string();
2238
2239        assert!(output_str.contains("path : \"/users\""));
2240        assert!(output_str.contains("__rapina_router . get (\"/users\""));
2241    }
2242
2243    #[test]
2244    #[should_panic(expected = "group prefix must start with `/`")]
2245    fn test_group_prefix_must_start_with_slash() {
2246        let attr = quote!("/users", group = "api");
2247        let input = quote! {
2248            async fn list_users() -> &'static str {
2249                "users"
2250            }
2251        };
2252
2253        route_macro_core("GET", attr, input);
2254    }
2255
2256    #[test]
2257    fn test_group_with_trailing_slash_normalized() {
2258        let attr = quote!("/users", group = "/api/");
2259        let input = quote! {
2260            async fn list_users() -> &'static str {
2261                "users"
2262            }
2263        };
2264
2265        let output = route_macro_core("GET", attr, input);
2266        let output_str = output.to_string();
2267
2268        assert!(output_str.contains("path : \"/api/users\""));
2269    }
2270
2271    #[test]
2272    fn test_group_with_public_attr() {
2273        let attr = quote!("/health", group = "/api");
2274        let input = quote! {
2275            #[public]
2276            async fn health() -> &'static str {
2277                "ok"
2278            }
2279        };
2280
2281        let output = route_macro_core("GET", attr, input);
2282        let output_str = output.to_string();
2283
2284        assert!(output_str.contains("path : \"/api/health\""));
2285        assert!(output_str.contains("is_public : true"));
2286    }
2287
2288    #[test]
2289    fn test_group_with_cache_attr() {
2290        let attr = quote!("/products", group = "/api");
2291        let input = quote! {
2292            #[cache(ttl = 60)]
2293            async fn list_products() -> &'static str {
2294                "products"
2295            }
2296        };
2297
2298        let output = route_macro_core("GET", attr, input);
2299        let output_str = output.to_string();
2300
2301        assert!(output_str.contains("path : \"/api/products\""));
2302        assert!(output_str.contains("x-rapina-cache-ttl"));
2303        assert!(output_str.contains("60"));
2304    }
2305
2306    #[test]
2307    fn test_group_with_errors_attr() {
2308        let attr = quote!("/users", group = "/api");
2309        let input = quote! {
2310            #[errors(UserError)]
2311            async fn get_user() -> Result<Json<UserResponse>> {
2312                Ok(Json(UserResponse { id: 1 }))
2313            }
2314        };
2315
2316        let output = route_macro_core("GET", attr, input);
2317        let output_str = output.to_string();
2318
2319        assert!(output_str.contains("path : \"/api/users\""));
2320        assert!(output_str.contains("fn error_responses"));
2321        assert!(output_str.contains("UserError"));
2322    }
2323
2324    #[test]
2325    fn test_group_with_all_methods() {
2326        for method in &["GET", "POST", "PUT", "DELETE"] {
2327            let attr = quote!("/items", group = "/api");
2328            let input = quote! {
2329                async fn handler() -> &'static str {
2330                    "ok"
2331                }
2332            };
2333
2334            let output = route_macro_core(method, attr, input);
2335            let output_str = output.to_string();
2336
2337            assert!(
2338                output_str.contains("path : \"/api/items\""),
2339                "{method} should produce /api/items"
2340            );
2341            let method_lower = method.to_lowercase();
2342            assert!(
2343                output_str.contains(&format!("__rapina_router . {method_lower}")),
2344                "{method} should use .{method_lower}() on router"
2345            );
2346        }
2347    }
2348
2349    #[test]
2350    fn test_join_paths_basic() {
2351        assert_eq!(join_paths("/api", "/users"), "/api/users");
2352        assert_eq!(join_paths("/api/v1", "/items"), "/api/v1/items");
2353    }
2354
2355    #[test]
2356    fn test_join_paths_trailing_slash() {
2357        assert_eq!(join_paths("/api/", "/users"), "/api/users");
2358    }
2359
2360    #[test]
2361    fn test_join_paths_empty_path() {
2362        assert_eq!(join_paths("/api", ""), "/api");
2363        assert_eq!(join_paths("/api", "/"), "/api");
2364    }
2365
2366    #[test]
2367    fn test_join_paths_empty_prefix() {
2368        assert_eq!(join_paths("", "/users"), "/users");
2369        assert_eq!(join_paths("", ""), "/");
2370    }
2371
2372    // -- #[job] retry policy attributes --
2373
2374    fn minimal_job_fn() -> proc_macro2::TokenStream {
2375        quote! {
2376            async fn my_job(payload: String) {}
2377        }
2378    }
2379
2380    #[test]
2381    fn job_macro_defaults_retry_policy_and_delay() {
2382        let output = job_macro_impl(quote! {}, minimal_job_fn()).to_string();
2383        assert!(
2384            output.contains("retry_policy : \"exponential\""),
2385            "default retry_policy should be exponential"
2386        );
2387        assert!(
2388            output.contains("retry_delay_secs : 1f64"),
2389            "default retry_delay_secs should be 1.0"
2390        );
2391    }
2392
2393    #[test]
2394    fn job_macro_fixed_retry_policy() {
2395        let output =
2396            job_macro_impl(quote! { retry_policy = "fixed" }, minimal_job_fn()).to_string();
2397        assert!(output.contains("retry_policy : \"fixed\""));
2398    }
2399
2400    #[test]
2401    fn job_macro_none_retry_policy() {
2402        let output = job_macro_impl(quote! { retry_policy = "none" }, minimal_job_fn()).to_string();
2403        assert!(output.contains("retry_policy : \"none\""));
2404    }
2405
2406    #[test]
2407    fn job_macro_retry_delay_float_literal() {
2408        let output =
2409            job_macro_impl(quote! { retry_delay_secs = 30.0 }, minimal_job_fn()).to_string();
2410        assert!(output.contains("retry_delay_secs : 30f64"));
2411    }
2412
2413    #[test]
2414    fn job_macro_retry_delay_integer_literal() {
2415        let output = job_macro_impl(quote! { retry_delay_secs = 30 }, minimal_job_fn()).to_string();
2416        assert!(output.contains("retry_delay_secs : 30f64"));
2417    }
2418
2419    #[test]
2420    fn job_macro_invalid_retry_policy_is_compile_error() {
2421        let output =
2422            job_macro_impl(quote! { retry_policy = "random" }, minimal_job_fn()).to_string();
2423        assert!(output.contains("compile_error"));
2424        assert!(
2425            output.contains("exponential") || output.contains("fixed") || output.contains("none")
2426        );
2427    }
2428
2429    #[test]
2430    fn job_macro_unknown_attr_error_mentions_retry_attrs() {
2431        let output = job_macro_impl(quote! { retries = 3 }, minimal_job_fn()).to_string();
2432        assert!(output.contains("compile_error"));
2433        assert!(output.contains("retry_policy"));
2434        assert!(output.contains("retry_delay_secs"));
2435    }
2436
2437    #[test]
2438    fn job_macro_all_retry_attrs_combined() {
2439        let output = job_macro_impl(
2440            quote! { retry_policy = "fixed", retry_delay_secs = 15, max_retries = 5 },
2441            minimal_job_fn(),
2442        )
2443        .to_string();
2444        assert!(output.contains("retry_policy : \"fixed\""));
2445        assert!(output.contains("retry_delay_secs : 15f64"));
2446    }
2447}