1use heck::ToKebabCase;
2use proc_macro::TokenStream;
3use quote::quote;
4use syn::spanned::Spanned;
5use syn::{FnArg, ItemFn, LitStr, Pat};
6
7struct 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
53fn 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#[proc_macro_attribute]
96pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
97 route_macro("GET", attr, item)
98}
99
100#[proc_macro_attribute]
104pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
105 route_macro("POST", attr, item)
106}
107
108#[proc_macro_attribute]
112pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
113 route_macro("PUT", attr, item)
114}
115
116#[proc_macro_attribute]
127pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
128 route_macro("PATCH", attr, item)
129}
130
131#[proc_macro_attribute]
135pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
136 route_macro("DELETE", attr, item)
137}
138
139#[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 let is_public = extract_public_attr(&mut func.attrs);
204
205 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 let error_type = extract_errors_attr(&mut func.attrs);
214
215 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 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 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 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 let header_by_arg: std::collections::HashMap<usize, &HeaderParamMeta> =
283 header_params.iter().map(|p| (p.arg_idx, p)).collect();
284
285 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 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 let return_type_annotation = match &func.sig.output {
321 syn::ReturnType::Type(_, ty) => quote! { : #ty },
322 syn::ReturnType::Default => quote! {},
323 };
324
325 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 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 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 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 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 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 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 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 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 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
560fn 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 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 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
584fn 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
599struct RequestBodyMeta {
601 inner_type: proc_macro2::TokenStream,
602 content_type: &'static str,
603 required: bool,
604}
605
606fn 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 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 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 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 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
655fn 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
678fn 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
688fn 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
711fn 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
721fn 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
749struct HeaderParamMeta {
751 arg_idx: usize,
753 name: String,
755 required: bool,
757 inner_type: syn::Type,
759}
760
761fn 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
773fn 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 if last.ident == "Header" {
790 let segments: Vec<_> = type_path.path.segments.iter().collect();
795 let is_rapina_header = match segments.len() {
796 1 => true, 2 => segments[0].ident == "extract", 3 => segments[0].ident == "rapina" && segments[1].ident == "extract", _ => 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 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
824fn 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 let explicit_name = extract_header_attr(&mut pat_type.attrs);
844
845 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 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#[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 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 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 #[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#[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#[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 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 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 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 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 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 #(#func_attrs)*
1318 #[doc(hidden)]
1319 async fn #impl_fn_name(#impl_inputs) #impl_output
1320 #func_block
1321
1322 #[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 #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#[proc_macro_derive(Config, attributes(env, default))]
1384pub fn derive_config(input: TokenStream) -> TokenStream {
1385 derive_config_impl(input.into()).into()
1386}
1387
1388#[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 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 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 assert!(output_str.contains("struct hello"));
1558 assert!(output_str.contains("impl rapina :: handler :: Handler for hello"));
1560 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 assert!(output_str.contains("FromRequest"));
1580 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 assert!(output_str.contains("struct create_user"));
1601 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 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 let output = route_macro_core("POST", path, input);
1623 let output_str = output.to_string();
1624
1625 assert!(output_str.contains("FromRequestParts"));
1627 assert!(output_str.contains("FromRequest"));
1629 }
1630
1631 #[test]
1632 fn test_custom_type_name_not_misclassified() {
1633 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 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 assert!(output_str.contains("FromRequestParts"));
1668 assert!(output_str.contains("FromRequest"));
1670 assert!(output_str.contains("into_parts"));
1672 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 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 assert!(output_str.contains("fn request_schema"));
1752 assert!(output_str.contains("CreateUserRequest"));
1753 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 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 assert!(output_str.contains("fn request_schema"));
1791 assert!(output_str.contains("CreateUserRequest"));
1792 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 assert!(output_str.contains("fn request_schema"));
1811 assert!(output_str.contains("LoginForm"));
1812 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 assert!(output_str.contains("fn request_schema"));
1831 assert!(output_str.contains("UpdateUserRequest"));
1832 assert!(output_str.contains("fn request_content_type"));
1834 assert!(output_str.contains("application/json"));
1835 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 assert!(output_str.contains("fn request_schema"));
1854 assert!(output_str.contains("LoginForm"));
1855 assert!(output_str.contains("fn request_content_type"));
1857 assert!(output_str.contains("application/x-www-form-urlencoded"));
1858 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 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 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 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 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 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 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 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 assert!(output_str.contains("__rapina_state"));
1951 assert!(output_str.contains("__rapina_params"));
1952 assert!(output_str.contains("let state ="));
1954 }
1955
1956 #[test]
1957 fn test_no_closure_wrapper_for_type_inference() {
1958 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 assert!(!output_str.contains("|| async"));
1971 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 assert!(output_str.contains("async fn room"));
2060 assert!(output_str.contains("__rapina_channel_room"));
2062 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 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 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 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}