1#![deny(missing_docs)]
20
21use proc_macro::TokenStream;
22use proc_macro2::{Span, TokenStream as TokenStream2};
23use quote::{format_ident, quote};
24use syn::parse::{Parse, ParseStream};
25use syn::punctuated::Punctuated;
26use syn::spanned::Spanned;
27use syn::{
28 parse_macro_input, parse_quote, Attribute, Block, Expr, ExprLit, Fields, FnArg,
29 GenericArgument, Ident, ImplItem, ItemFn, ItemImpl, ItemStruct, Lit, LitInt, LitStr, Meta,
30 PatType, Path, PathArguments, ReturnType, Token, Type,
31};
32
33#[proc_macro_attribute]
41#[allow(non_snake_case)]
42pub fn Injectable(_attr: TokenStream, item: TokenStream) -> TokenStream {
43 let st = parse_macro_input!(item as ItemStruct);
44 let name = st.ident.clone();
45 let name_str = name.to_string();
46
47 let mut deps_tys: Vec<Type> = Vec::new();
51 let mut ctor_field_inits: Vec<TokenStream2> = Vec::new();
52
53 match &st.fields {
54 Fields::Named(named) => {
55 for f in &named.named {
56 let fname = f.ident.as_ref().unwrap();
57 if let Some(inner) = inject_inner_ty(&f.ty) {
58 deps_tys.push(inner.clone());
59 ctor_field_inits.push(quote! {
60 #fname: ::arcly_http::__macro_support::Inject::__from_static(
61 __r.get::<#inner>(),
62 )
63 });
64 } else {
65 let fty = &f.ty;
66 ctor_field_inits.push(quote! {
67 #fname: <#fty as ::core::default::Default>::default()
68 });
69 }
70 }
71 }
72 Fields::Unit => {
73 }
75 Fields::Unnamed(_) => {
76 return syn::Error::new(
77 st.fields.span(),
78 "#[Injectable] does not yet support tuple structs — use a named-field struct",
79 )
80 .to_compile_error()
81 .into();
82 }
83 }
84
85 let deps_ty_paths: Vec<TokenStream2> = deps_tys.iter().map(|t| quote!(#t)).collect();
86 let desc_name = format_ident!("__ARCLY_PROVIDER_{}", name_str.to_uppercase());
87
88 let ctor_body = match &st.fields {
89 Fields::Named(_) => quote! { #name { #( #ctor_field_inits ),* } },
90 Fields::Unit => quote! { #name },
91 Fields::Unnamed(_) => unreachable!(),
92 };
93
94 quote! {
95 #st
96
97 impl #name {
98 #[doc(hidden)]
99 pub fn __arcly_build(__r: &::arcly_http::__macro_support::Resolver<'_>) -> Self {
100 #ctor_body
101 }
102 }
103
104 #[allow(non_upper_case_globals)]
105 static #desc_name: ::arcly_http::__macro_support::ProviderDescriptor =
106 ::arcly_http::__macro_support::ProviderDescriptor {
107 name: #name_str,
108 type_id_fn: || ::core::any::TypeId::of::<#name>(),
109 deps_fn: || ::std::vec![
110 #( ::core::any::TypeId::of::<#deps_ty_paths>() ),*
111 ],
112 build: |__r| ::std::sync::Arc::new(#name::__arcly_build(__r)),
113 };
114
115 impl #name {
116 #[doc(hidden)]
117 pub const fn __arcly_descriptor() -> &'static ::arcly_http::__macro_support::ProviderDescriptor {
118 &#desc_name
119 }
120 }
121 }
122 .into()
123}
124
125fn inject_inner_ty(ty: &Type) -> Option<&Type> {
126 let Type::Path(tp) = ty else { return None };
127 let seg = tp.path.segments.last()?;
128 if seg.ident != "Inject" {
129 return None;
130 }
131 first_generic(&seg.arguments)
132}
133
134struct ModuleArgs {
138 providers: Vec<Path>,
139 controllers: Vec<Path>,
140 imports: Vec<Path>,
141 gateways: Vec<Path>,
142}
143
144impl Parse for ModuleArgs {
145 fn parse(input: ParseStream) -> syn::Result<Self> {
146 let mut out = ModuleArgs {
147 providers: vec![],
148 controllers: vec![],
149 imports: vec![],
150 gateways: vec![],
151 };
152 while !input.is_empty() {
153 let key: Ident = input.parse()?;
154 let content;
155 syn::parenthesized!(content in input);
156 let list: Punctuated<Path, Token![,]> =
157 content.parse_terminated(Path::parse, Token![,])?;
158 match key.to_string().as_str() {
159 "providers" => out.providers.extend(list),
160 "controllers" => out.controllers.extend(list),
161 "imports" => out.imports.extend(list),
162 "gateways" => out.gateways.extend(list),
163 other => return Err(syn::Error::new(
164 key.span(),
165 format!("unknown Module key `{other}` (expected providers/controllers/imports/gateways)"),
166 )),
167 }
168 let _ = input.parse::<Token![,]>();
169 }
170 Ok(out)
171 }
172}
173
174#[proc_macro_attribute]
181#[allow(non_snake_case)]
182pub fn Module(attr: TokenStream, item: TokenStream) -> TokenStream {
183 let args = parse_macro_input!(attr as ModuleArgs);
184 let st = parse_macro_input!(item as ItemStruct);
185 let st_name = &st.ident;
186 let mod_name_str = st_name.to_string();
187
188 let provider_refs: Vec<TokenStream2> = args
189 .providers
190 .iter()
191 .map(|p| {
192 quote! { <#p>::__arcly_descriptor() }
193 })
194 .collect();
195
196 let controller_names: Vec<TokenStream2> = args
200 .controllers
201 .iter()
202 .map(|p| {
203 let n = p
204 .segments
205 .last()
206 .map(|s| s.ident.to_string())
207 .unwrap_or_default();
208 quote! { #n }
209 })
210 .collect();
211
212 let gateway_names: Vec<TokenStream2> = args
215 .gateways
216 .iter()
217 .map(|p| {
218 let n = p
219 .segments
220 .last()
221 .map(|s| s.ident.to_string())
222 .unwrap_or_default();
223 quote! { #n }
224 })
225 .collect();
226
227 let import_fns: Vec<TokenStream2> = args
231 .imports
232 .iter()
233 .map(|p| {
234 quote! { (<#p as ::arcly_http::__macro_support::Module>::descriptor) }
235 })
236 .collect();
237
238 let static_name = format_ident!("__ARCLY_MODULE_{}", st_name.to_string().to_uppercase());
239
240 quote! {
241 #st
242
243 #[allow(non_upper_case_globals)]
244 static #static_name: ::arcly_http::__macro_support::ModuleDescriptor =
245 ::arcly_http::__macro_support::ModuleDescriptor {
246 name: #mod_name_str,
247 providers: &[ #( #provider_refs ),* ],
248 controllers: &[ #( #controller_names ),* ],
249 imports: &[ #( #import_fns ),* ],
250 gateways: &[ #( #gateway_names ),* ],
251 };
252
253 impl ::arcly_http::__macro_support::Module for #st_name {
254 fn descriptor() -> &'static ::arcly_http::__macro_support::ModuleDescriptor {
255 &#static_name
256 }
257 }
258
259 ::arcly_http::inventory::submit! {
260 &#static_name
261 }
262 }
263 .into()
264}
265
266struct RouteArgs {
270 path: LitStr,
271 guards: Vec<Expr>,
272 tags: Vec<LitStr>,
273 security: Vec<LitStr>,
274 summary: Option<LitStr>,
275 description: Option<LitStr>,
276 operation_id: Option<LitStr>,
277 status: Option<LitInt>,
278 deprecated: bool,
279}
280
281impl Parse for RouteArgs {
282 fn parse(input: ParseStream) -> syn::Result<Self> {
283 let path: LitStr = input.parse()?;
284 let mut out = RouteArgs {
285 path,
286 guards: vec![],
287 tags: vec![],
288 security: vec![],
289 summary: None,
290 description: None,
291 operation_id: None,
292 status: None,
293 deprecated: false,
294 };
295 while input.peek(Token![,]) {
296 let _: Token![,] = input.parse()?;
297 if input.is_empty() {
298 break;
299 }
300 let key: Ident = input.parse()?;
301 let k = key.to_string();
302 if k == "deprecated" {
303 out.deprecated = true;
304 continue;
305 }
306 let content;
307 syn::parenthesized!(content in input);
308 match k.as_str() {
309 "guards" => {
310 let list: Punctuated<Expr, Token![,]> = content.parse_terminated(Expr::parse, Token![,])?;
311 out.guards.extend(list);
312 }
313 "tags" => {
314 let list: Punctuated<LitStr, Token![,]> = content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
315 out.tags.extend(list);
316 }
317 "security" => {
318 let list: Punctuated<LitStr, Token![,]> = content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
319 out.security.extend(list);
320 }
321 "summary" => out.summary = Some(content.parse()?),
322 "description" => out.description = Some(content.parse()?),
323 "operation_id" => out.operation_id = Some(content.parse()?),
324 "status" => out.status = Some(content.parse()?),
325 other => return Err(syn::Error::new(
326 key.span(),
327 format!("unknown route key `{other}` (expected guards/tags/security/summary/description/operation_id/status/deprecated)"),
328 )),
329 }
330 }
331 Ok(out)
332 }
333}
334
335struct ControllerArgs {
339 prefix: LitStr,
340 tags: Vec<LitStr>,
341}
342impl Parse for ControllerArgs {
343 fn parse(input: ParseStream) -> syn::Result<Self> {
344 let prefix: LitStr = input.parse()?;
345 let mut tags: Vec<LitStr> = vec![];
346 if input.peek(Token![,]) {
347 let _: Token![,] = input.parse()?;
348 if !input.is_empty() {
349 let key: Ident = input.parse()?;
350 if key != "tags" {
351 return Err(syn::Error::new(key.span(), "expected `tags(...)`"));
352 }
353 let content;
354 syn::parenthesized!(content in input);
355 let list: Punctuated<LitStr, Token![,]> =
356 content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
357 tags.extend(list);
358 }
359 }
360 Ok(Self { prefix, tags })
361 }
362}
363
364#[proc_macro_attribute]
369#[allow(non_snake_case)]
370pub fn Controller(attr: TokenStream, item: TokenStream) -> TokenStream {
371 if let Ok(imp) = syn::parse::<ItemImpl>(item.clone()) {
375 return controller_on_impl(attr, imp);
376 }
377 item
378}
379
380fn controller_on_impl(attr: TokenStream, mut imp: ItemImpl) -> TokenStream {
381 let ControllerArgs {
382 prefix,
383 tags: ctrl_tags,
384 } = parse_macro_input!(attr as ControllerArgs);
385
386 let mut api_version = String::new();
389 let mut sunset = String::new();
390 {
391 let mut keep: Vec<Attribute> = Vec::with_capacity(imp.attrs.len());
392 for a in imp.attrs.drain(..) {
393 let id = a
394 .path()
395 .get_ident()
396 .map(|i| i.to_string())
397 .unwrap_or_default();
398 match id.as_str() {
399 "Version" => {
400 if let Ok(v) = a.parse_args::<LitStr>() {
401 api_version = v.value().trim_matches('/').to_owned();
402 }
403 }
404 "Deprecated" => {
405 let _ = a.parse_nested_meta(|meta| {
406 if meta.path.is_ident("sunset") {
407 let v: LitStr = meta.value()?.parse()?;
408 sunset = v.value();
409 }
410 Ok(())
411 });
412 }
413 _ => keep.push(a),
414 }
415 }
416 imp.attrs = keep;
417 }
418
419 let raw_prefix = prefix.value();
421 let prefix_str = if api_version.is_empty() {
422 raw_prefix
423 } else {
424 format!("/{api_version}{raw_prefix}")
425 };
426 let self_ty = (*imp.self_ty).clone();
427
428 let controller_name = match &self_ty {
431 Type::Path(tp) => tp
432 .path
433 .segments
434 .last()
435 .map(|s| s.ident.to_string())
436 .unwrap_or_default(),
437 _ => String::new(),
438 };
439
440 let mut route_registrations: Vec<TokenStream2> = Vec::new();
441 let mut errors: Vec<syn::Error> = Vec::new();
442
443 for item in imp.items.iter_mut() {
444 let ImplItem::Fn(m) = item else { continue };
445
446 let mut route_attr_idx: Option<usize> = None;
448 let mut route_method: Option<&'static str> = None;
449 let mut interceptor_attr_idxs: Vec<usize> = Vec::new();
450
451 for (i, a) in m.attrs.iter().enumerate() {
452 let ident = a
453 .path()
454 .get_ident()
455 .map(|i| i.to_string())
456 .unwrap_or_default();
457 match ident.as_str() {
458 "Get" | "Post" | "Put" | "Delete" | "Patch" => {
459 route_attr_idx = Some(i);
460 route_method = Some(match ident.as_str() {
461 "Get" => "GET",
462 "Post" => "POST",
463 "Put" => "PUT",
464 "Delete" => "DELETE",
465 "Patch" => "PATCH",
466 _ => unreachable!(),
467 });
468 }
469 "UseInterceptors" => interceptor_attr_idxs.push(i),
470 _ => {}
471 }
472 }
473
474 let Some(idx) = route_attr_idx else { continue };
475 let route_method = route_method.unwrap();
476
477 let route_attr = m.attrs[idx].clone();
479 let route_args: RouteArgs = match route_attr.parse_args() {
480 Ok(a) => a,
481 Err(e) => {
482 errors.push(e);
483 continue;
484 }
485 };
486
487 let mut interceptor_paths: Vec<Path> = Vec::new();
489 for i in &interceptor_attr_idxs {
490 let a = &m.attrs[*i];
491 match a.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated) {
492 Ok(list) => interceptor_paths.extend(list),
493 Err(e) => errors.push(e),
494 }
495 }
496
497 let local_path = route_args.path.value();
499 let full = join_paths(&prefix_str, &local_path);
500
501 let merged_tags: Vec<LitStr> = ctrl_tags
503 .iter()
504 .cloned()
505 .chain(route_args.tags.iter().cloned())
506 .collect();
507
508 let mut keep: Vec<Attribute> = Vec::with_capacity(m.attrs.len());
511 for (i, a) in m.attrs.iter().enumerate() {
512 if i == idx || interceptor_attr_idxs.contains(&i) {
513 continue;
514 }
515 keep.push(a.clone());
516 }
517 m.attrs = keep;
518
519 let (cache_ttl_secs, cache_key) = match harvest_cache_attrs(&mut m.attrs) {
520 Ok(p) => p,
521 Err(e) => {
522 errors.push(e);
523 continue;
524 }
525 };
526
527 let audit = match harvest_audit_attr(&mut m.attrs) {
528 Ok(a) => a,
529 Err(e) => {
530 errors.push(e);
531 continue;
532 }
533 };
534
535 let timeout_ms = match harvest_timeout_attr(&mut m.attrs) {
536 Ok(t) => t,
537 Err(e) => {
538 errors.push(e);
539 continue;
540 }
541 };
542
543 let transactional = harvest_transactional_attr(&mut m.attrs);
544
545 let idempotent_ttl = match harvest_idempotent_attr(&mut m.attrs) {
546 Ok(t) => t,
547 Err(e) => {
548 errors.push(e);
549 continue;
550 }
551 };
552
553 let policies = match harvest_policies_attr(&mut m.attrs) {
554 Ok(p) => p,
555 Err(e) => {
556 errors.push(e);
557 continue;
558 }
559 };
560
561 let mask_fields = match harvest_mask_attr(&mut m.attrs) {
562 Ok(f) => f,
563 Err(e) => {
564 errors.push(e);
565 continue;
566 }
567 };
568 let multipart = match harvest_multipart_attr(&mut m.attrs) {
569 Ok(f) => f,
570 Err(e) => {
571 errors.push(e);
572 continue;
573 }
574 };
575
576 let reg = match build_method_route_registration(
578 &self_ty,
579 m,
580 route_method,
581 full,
582 &route_args,
583 &merged_tags,
584 &interceptor_paths,
585 cache_ttl_secs,
586 &cache_key,
587 &controller_name,
588 &audit,
589 timeout_ms,
590 &api_version,
591 &sunset,
592 transactional,
593 idempotent_ttl,
594 &policies,
595 &mask_fields,
596 multipart.as_deref(),
597 ) {
598 Ok(ts) => ts,
599 Err(e) => {
600 errors.push(e);
601 continue;
602 }
603 };
604 route_registrations.push(reg);
605
606 for input in m.sig.inputs.iter_mut() {
608 if let FnArg::Typed(pt) = input {
609 pt.attrs.retain(|a| {
610 let id = a
611 .path()
612 .get_ident()
613 .map(|i| i.to_string())
614 .unwrap_or_default();
615 !matches!(id.as_str(), "Param" | "Query" | "Body" | "Header")
616 });
617 }
618 }
619 }
620
621 if let Some(err) = errors.into_iter().reduce(|mut a, b| {
622 a.combine(b);
623 a
624 }) {
625 return err.to_compile_error().into();
626 }
627
628 quote! {
629 #imp
630 #( #route_registrations )*
631 }
632 .into()
633}
634
635fn harvest_cache_attrs(attrs: &mut Vec<Attribute>) -> syn::Result<(u64, String)> {
639 let mut ttl_secs: u64 = 0;
640 let mut key: String = String::new();
641 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
642 for a in attrs.drain(..) {
643 let id = a
644 .path()
645 .get_ident()
646 .map(|i| i.to_string())
647 .unwrap_or_default();
648 match id.as_str() {
649 "CacheTTL" => {
650 let n: LitInt = a.parse_args()?;
651 ttl_secs = n.base10_parse()?;
652 }
653 "CacheKey" => {
654 let s: LitStr = a.parse_args()?;
655 key = s.value();
656 }
657 _ => {
658 keep.push(a);
659 }
660 }
661 }
662 *attrs = keep;
663 Ok((ttl_secs, key))
664}
665
666fn harvest_audit_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<(String, String)>> {
669 let mut found: Option<(String, String)> = None;
670 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
671 for a in attrs.drain(..) {
672 let id = a
673 .path()
674 .get_ident()
675 .map(|i| i.to_string())
676 .unwrap_or_default();
677 if id == "AuditLog" {
678 let mut action = String::new();
679 let mut resource = String::new();
680 a.parse_nested_meta(|meta| {
681 if meta.path.is_ident("action") {
682 let v: LitStr = meta.value()?.parse()?;
683 action = v.value();
684 } else if meta.path.is_ident("resource") {
685 let v: LitStr = meta.value()?.parse()?;
686 resource = v.value();
687 } else {
688 return Err(meta.error("expected `action = \"…\"` or `resource = \"…\"`"));
689 }
690 Ok(())
691 })?;
692 if action.is_empty() {
693 return Err(syn::Error::new_spanned(
694 &a,
695 "#[AuditLog] requires action = \"…\"",
696 ));
697 }
698 found = Some((action, resource));
699 } else {
700 keep.push(a);
701 }
702 }
703 *attrs = keep;
704 Ok(found)
705}
706
707fn harvest_timeout_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<u64>> {
709 let mut found: Option<u64> = None;
710 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
711 for a in attrs.drain(..) {
712 let id = a
713 .path()
714 .get_ident()
715 .map(|i| i.to_string())
716 .unwrap_or_default();
717 if id == "Timeout" {
718 let lit: LitStr = a.parse_args()?;
719 let raw = lit.value();
720 let ms = parse_duration_str_ms(&raw).ok_or_else(|| {
721 syn::Error::new_spanned(&a, "expected a duration like \"250ms\", \"2s\", or \"1m\"")
722 })?;
723 found = Some(ms);
724 } else {
725 keep.push(a);
726 }
727 }
728 *attrs = keep;
729 Ok(found)
730}
731
732fn parse_duration_str_ms(s: &str) -> Option<u64> {
733 let s = s.trim();
734 if let Some(v) = s.strip_suffix("ms") {
735 return v.trim().parse().ok();
736 }
737 if let Some(v) = s.strip_suffix('s') {
738 return v.trim().parse::<u64>().ok().map(|n| n * 1_000);
739 }
740 if let Some(v) = s.strip_suffix('h') {
741 return v.trim().parse::<u64>().ok().map(|n| n * 3_600_000);
742 }
743 if let Some(v) = s.strip_suffix('m') {
744 return v.trim().parse::<u64>().ok().map(|n| n * 60_000);
745 }
746 s.parse().ok()
747}
748
749fn harvest_transactional_attr(attrs: &mut Vec<Attribute>) -> bool {
751 let before = attrs.len();
752 attrs.retain(|a| {
753 a.path()
754 .get_ident()
755 .map(|i| i.to_string())
756 .unwrap_or_default()
757 != "Transactional"
758 });
759 attrs.len() != before
760}
761
762fn harvest_idempotent_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<u64>> {
764 let mut found: Option<u64> = None;
765 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
766 for a in attrs.drain(..) {
767 let id = a
768 .path()
769 .get_ident()
770 .map(|i| i.to_string())
771 .unwrap_or_default();
772 if id == "Idempotent" {
773 let mut ttl_secs: u64 = 24 * 3600;
774 a.parse_nested_meta(|meta| {
775 if meta.path.is_ident("ttl") {
776 let v: LitStr = meta.value()?.parse()?;
777 let ms = parse_duration_str_ms(&v.value())
778 .ok_or_else(|| meta.error("expected a duration like \"30m\", \"24h\""))?;
779 ttl_secs = (ms / 1000).max(1);
780 }
781 Ok(())
782 })?;
783 found = Some(ttl_secs);
784 } else {
785 keep.push(a);
786 }
787 }
788 *attrs = keep;
789 Ok(found)
790}
791
792fn harvest_policies_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Vec<LitStr>> {
794 let mut found: Vec<LitStr> = Vec::new();
795 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
796 for a in attrs.drain(..) {
797 let id = a
798 .path()
799 .get_ident()
800 .map(|i| i.to_string())
801 .unwrap_or_default();
802 if id == "RequirePolicies" {
803 let list = a.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated)?;
804 found.extend(list);
805 } else {
806 keep.push(a);
807 }
808 }
809 *attrs = keep;
810 Ok(found)
811}
812
813fn harvest_mask_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Vec<LitStr>> {
815 let mut found: Vec<LitStr> = Vec::new();
816 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
817 for a in attrs.drain(..) {
818 let id = a
819 .path()
820 .get_ident()
821 .map(|i| i.to_string())
822 .unwrap_or_default();
823 if id == "MaskFields" {
824 let list = a.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated)?;
825 found.extend(list);
826 } else {
827 keep.push(a);
828 }
829 }
830 *attrs = keep;
831 Ok(found)
832}
833
834struct MultipartField {
836 is_file: bool,
839 name: LitStr,
840}
841
842fn harvest_multipart_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<Vec<MultipartField>>> {
847 let mut found: Option<Vec<MultipartField>> = None;
848 let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
849 for a in attrs.drain(..) {
850 let id = a
851 .path()
852 .get_ident()
853 .map(|i| i.to_string())
854 .unwrap_or_default();
855 if id == "Multipart" {
856 let mut fields = Vec::new();
857 if !matches!(a.meta, Meta::Path(_)) {
859 let metas = a.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
860 for m in metas {
861 let Meta::List(list) = &m else {
862 return Err(syn::Error::new(
863 m.span(),
864 "expected `file(\"name\")` or `text(\"name\")`",
865 ));
866 };
867 let kind = list
868 .path
869 .get_ident()
870 .map(|i| i.to_string())
871 .unwrap_or_default();
872 let is_file = match kind.as_str() {
873 "file" => true,
874 "text" => false,
875 _ => {
876 return Err(syn::Error::new(
877 list.path.span(),
878 "#[Multipart] parts must be `file(\"…\")` or `text(\"…\")`",
879 ))
880 }
881 };
882 let name: LitStr = list.parse_args()?;
883 fields.push(MultipartField { is_file, name });
884 }
885 }
886 found = Some(fields);
887 } else {
888 keep.push(a);
889 }
890 }
891 *attrs = keep;
892 Ok(found)
893}
894
895fn multipart_schema_expr(fields: &[MultipartField]) -> TokenStream2 {
899 let inserts = fields.iter().map(|f| {
900 let name = &f.name;
901 if f.is_file {
902 quote! {
903 __props.insert(
904 #name.to_string(),
905 ::arcly_http::serde_json::json!({ "type": "string", "format": "binary" }),
906 );
907 }
908 } else {
909 quote! {
910 __props.insert(
911 #name.to_string(),
912 ::arcly_http::serde_json::json!({ "type": "string" }),
913 );
914 }
915 }
916 });
917 let required = fields.iter().filter(|f| f.is_file).map(|f| {
918 let n = &f.name;
919 quote!(#n)
920 });
921 quote! {
922 ::core::option::Option::Some(|| {
923 let mut __props = ::arcly_http::serde_json::Map::new();
924 #( #inserts )*
925 ::arcly_http::serde_json::json!({
926 "type": "object",
927 "properties": ::arcly_http::serde_json::Value::Object(__props),
928 "required": [ #( #required ),* ],
929 })
930 })
931 }
932}
933
934fn join_paths(prefix: &str, local: &str) -> String {
935 let p = prefix.trim_end_matches('/');
936 let l = if local.starts_with('/') {
937 local
938 } else {
939 return format!("{p}/{local}");
940 };
941 if p.is_empty() {
942 l.to_owned()
943 } else {
944 format!("{p}{l}")
945 }
946}
947
948fn route_idents(controller: &str, fn_name: &str) -> (Ident, Ident, Ident) {
963 let (thunk, desc, spec) = if controller.is_empty() {
964 (
965 format!("__arcly_thunk_{fn_name}"),
966 format!("__ARCLY_ROUTE_{}", fn_name.to_uppercase()),
967 format!("__ARCLY_SPEC_{}", fn_name.to_uppercase()),
968 )
969 } else {
970 (
971 format!("__arcly_thunk_{controller}_{fn_name}"),
972 format!(
973 "__ARCLY_ROUTE_{}_{}",
974 controller.to_uppercase(),
975 fn_name.to_uppercase()
976 ),
977 format!(
978 "__ARCLY_SPEC_{}_{}",
979 controller.to_uppercase(),
980 fn_name.to_uppercase()
981 ),
982 )
983 };
984 (
985 format_ident!("{}", thunk),
986 format_ident!("{}", desc),
987 format_ident!("{}", spec),
988 )
989}
990
991#[allow(clippy::too_many_arguments)]
995fn build_method_route_registration(
996 self_ty: &Type,
997 m: &syn::ImplItemFn,
998 method: &'static str,
999 full_path: String,
1000 args: &RouteArgs,
1001 tags: &[LitStr],
1002 interceptors: &[Path],
1003 cache_ttl_secs: u64,
1004 cache_key: &str,
1005 controller_name: &str,
1006 audit: &Option<(String, String)>,
1007 timeout_ms: Option<u64>,
1008 api_version: &str,
1009 sunset: &str,
1010 transactional: bool,
1011 idempotent_ttl: Option<u64>,
1012 policies: &[LitStr],
1013 mask_fields: &[LitStr],
1014 multipart: Option<&[MultipartField]>,
1015) -> syn::Result<TokenStream2> {
1016 let fn_name = m.sig.ident.clone();
1017 let (thunk_name, desc_name, spec_name) = route_idents(controller_name, &fn_name.to_string());
1020 let method_ident = Ident::new(method, Span::call_site());
1021
1022 let doc = collect_doc_comments(&m.attrs);
1023
1024 let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1025 let mut call_args: Vec<TokenStream2> = Vec::new();
1026 let mut spec_params: Vec<TokenStream2> = Vec::new();
1027 let mut has_body = false;
1028 let mut body_ty: Option<Type> = None;
1029 let mut query_ty: Option<Type> = None;
1030
1031 for (i, input) in m.sig.inputs.iter().enumerate() {
1032 let FnArg::Typed(pt) = input else {
1033 return Err(syn::Error::new(
1034 input.span(),
1035 "controller methods must not take `self` — use Inject<T> fields instead",
1036 ));
1037 };
1038 let var = format_ident!("__arg_{i}");
1039 let (kind, ty) = classify_arg(pt)?;
1040 let stmt = emit_extractor(
1041 &kind,
1042 &ty,
1043 &var,
1044 &mut spec_params,
1045 &mut has_body,
1046 &mut body_ty,
1047 &mut query_ty,
1048 );
1049 extract_stmts.push(stmt);
1050 call_args.push(quote! { #var });
1051 }
1052
1053 let mut guard_stmts: Vec<TokenStream2> = args
1054 .guards
1055 .iter()
1056 .map(|g| {
1057 quote! {
1058 <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
1059 }
1060 })
1061 .collect();
1062
1063 if !policies.is_empty() {
1067 let action_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
1068 guard_stmts.push(quote! {
1069 ::arcly_http::__macro_support::check_policies(
1070 &ctx, &[ #( #action_lits ),* ], ::arcly_http::serde_json::Value::Null,
1071 )?;
1072 });
1073 }
1074
1075 let run_body = if transactional {
1080 quote! {
1085 let __tx_ctx = ctx.clone();
1086 ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1087 ::arcly_http::__macro_support::run_transactional(&__tx_ctx, async move {
1088 #( #guard_stmts )*
1089 #( #extract_stmts )*
1090 <#self_ty>::#fn_name( #( #call_args ),* ).await
1091 }).await
1092 )
1093 }
1094 } else {
1095 quote! {
1096 #( #guard_stmts )*
1097 #( #extract_stmts )*
1098 ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1099 <#self_ty>::#fn_name( #( #call_args ),* ).await
1100 )
1101 }
1102 };
1103
1104 let inner = quote! {
1105 let __run = async move {
1106 #run_body
1107 };
1108 match __run.await {
1109 ::core::result::Result::Ok(v) => {
1110 ::arcly_http::__axum::response::IntoResponse::into_response(v)
1111 }
1112 ::core::result::Result::Err(e) => {
1113 ::arcly_http::__axum::response::IntoResponse::into_response(e)
1114 }
1115 }
1116 };
1117
1118 let inner = match timeout_ms {
1122 Some(ms) => {
1123 let route_lit = LitStr::new(&full_path, Span::call_site());
1124 quote! {
1125 ::arcly_http::__macro_support::run_with_timeout(
1126 #ms, #route_lit, async move { #inner },
1127 ).await
1128 }
1129 }
1130 None => inner,
1131 };
1132
1133 let inner = match audit {
1137 Some((action, resource)) => {
1138 let action_lit = LitStr::new(action, Span::call_site());
1139 let resource_lit = LitStr::new(resource, Span::call_site());
1140 quote! {
1141 let __audit_ctx = ctx.clone();
1142 let __resp: ::arcly_http::__axum::response::Response = { #inner };
1143 ::arcly_http::__macro_support::emit_route_audit(
1144 &__audit_ctx, #action_lit, #resource_lit, __resp.status().as_u16(),
1145 );
1146 __resp
1147 }
1148 }
1149 None => inner,
1150 };
1151
1152 let inner = if mask_fields.is_empty() {
1155 inner
1156 } else {
1157 let mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1158 quote! {
1159 let __mask_ctx = ctx.clone();
1160 let __resp: ::arcly_http::__axum::response::Response = { #inner };
1161 ::arcly_http::__macro_support::mask_response(
1162 &__mask_ctx, &[ #( #mask_lits ),* ], __resp,
1163 ).await
1164 }
1165 };
1166
1167 let inner = match idempotent_ttl {
1170 Some(ttl) => {
1171 let route_lit = LitStr::new(&full_path, Span::call_site());
1172 quote! {
1173 let __idem_ctx = ctx.clone();
1174 ::arcly_http::__macro_support::run_idempotent(
1175 &__idem_ctx, #ttl, #route_lit, async move { #inner },
1176 ).await
1177 }
1178 }
1179 None => inner,
1180 };
1181
1182 let thunk_body = wrap_interceptors(inner, interceptors);
1183
1184 let fn_str = fn_name.to_string();
1185 let summary_str = args
1186 .summary
1187 .as_ref()
1188 .map(|s| s.value())
1189 .unwrap_or_else(|| fn_str.clone());
1190 let operation_id = args
1191 .operation_id
1192 .as_ref()
1193 .map(|s| s.value())
1194 .unwrap_or_else(|| fn_str.clone());
1195 let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1196 let deprecated = args.deprecated;
1197 let tag_lits: Vec<TokenStream2> = tags.iter().map(|t| quote!(#t)).collect();
1198 let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1199 let status_expr = match &args.status {
1200 Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1201 None => quote! { ::core::option::Option::None },
1202 };
1203 let (consumes_lit, body_schema_expr, has_body) = match multipart {
1207 Some(fields) => (
1208 LitStr::new("multipart/form-data", Span::call_site()),
1209 multipart_schema_expr(fields),
1210 true,
1211 ),
1212 None => (
1213 LitStr::new("application/json", Span::call_site()),
1214 schema_expr(&body_ty),
1215 has_body,
1216 ),
1217 };
1218 let query_schema_expr = schema_expr(&query_ty);
1219 let response_schema_expr = schema_expr(&extract_response_ty(&m.sig.output));
1220 let full_path_lit = LitStr::new(&full_path, Span::call_site());
1221
1222 let spec_idem_ttl = idempotent_ttl.unwrap_or(0);
1224 let spec_policy_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
1225 let (spec_audit_action, spec_audit_resource) = match audit {
1226 Some((a, r)) => (a.clone(), r.clone()),
1227 None => (String::new(), String::new()),
1228 };
1229 let spec_timeout_ms = timeout_ms.unwrap_or(0);
1230 let spec_mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1231
1232 Ok(quote! {
1233 fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1234 -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1235 {
1236 ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1237 }
1238
1239 #[allow(non_upper_case_globals)]
1240 static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1241 ::arcly_http::__macro_support::RouteSpec {
1242 summary: #summary_str,
1243 description: #description_str,
1244 operation_id: #operation_id,
1245 tags: &[ #( #tag_lits ),* ],
1246 security: &[ #( #sec_lits ),* ],
1247 status_code: #status_expr,
1248 deprecated: #deprecated,
1249 params: &[ #( #spec_params ),* ],
1250 has_body: #has_body,
1251 body_schema: #body_schema_expr,
1252 consumes: #consumes_lit,
1253 query_schema: #query_schema_expr,
1254 response_schema: #response_schema_expr,
1255 cache_ttl_secs: #cache_ttl_secs,
1256 cache_key: #cache_key,
1257 api_version: #api_version,
1258 sunset: #sunset,
1259 idempotent_ttl_secs: #spec_idem_ttl,
1260 policies: &[ #( #spec_policy_lits ),* ],
1261 audit_action: #spec_audit_action,
1262 audit_resource: #spec_audit_resource,
1263 timeout_ms: #spec_timeout_ms,
1264 transactional: #transactional,
1265 mask_fields: &[ #( #spec_mask_lits ),* ],
1266 };
1267
1268 #[allow(non_upper_case_globals)]
1269 static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1270 ::arcly_http::__macro_support::RouteDescriptor {
1271 method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1272 path: #full_path_lit,
1273 handler: #thunk_name,
1274 spec: &#spec_name,
1275 controller: #controller_name,
1276 };
1277
1278 ::arcly_http::inventory::submit! {
1279 &#desc_name
1280 }
1281 })
1282}
1283
1284fn schema_expr(ty: &Option<Type>) -> TokenStream2 {
1285 match ty {
1286 Some(t) => quote! { ::core::option::Option::Some(|| ::arcly_http::__schema_for::<#t>()) },
1287 None => quote! { ::core::option::Option::None },
1288 }
1289}
1290
1291fn wrap_interceptors(inner: TokenStream2, interceptors: &[Path]) -> TokenStream2 {
1303 match interceptors.len() {
1304 0 => return inner,
1305 1 => {
1306 let icp = &interceptors[0];
1307 return quote! {
1308 {
1309 static __ICP: #icp = #icp;
1310 let __inner = ::arcly_http::__macro_support::NextHandler::new(
1311 move |ctx: ::arcly_http::__macro_support::RequestContext|
1312 ::arcly_http::futures::FutureExt::boxed(async move {
1313 let ctx = ctx;
1314 #inner
1315 })
1316 );
1317 <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner).await
1318 }
1319 };
1320 }
1321 _ => {}
1322 }
1323
1324 let mut current = quote! {
1326 ::arcly_http::__macro_support::NextHandler::new(
1327 move |ctx: ::arcly_http::__macro_support::RequestContext|
1328 ::arcly_http::futures::FutureExt::boxed(async move {
1329 let ctx = ctx;
1330 #inner
1331 })
1332 )
1333 };
1334 for icp in interceptors.iter().rev() {
1335 current = quote! {
1336 {
1337 static __ICP: #icp = #icp;
1338 let __inner = #current;
1339 ::arcly_http::__macro_support::NextHandler::new(
1340 move |ctx: ::arcly_http::__macro_support::RequestContext| {
1341 <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner.__clone_for_chain())
1342 },
1343 )
1344 }
1345 };
1346 }
1347 quote! { #current.run(ctx).await }
1348}
1349
1350#[proc_macro_attribute]
1356#[allow(non_snake_case)]
1357pub fn Get(a: TokenStream, i: TokenStream) -> TokenStream {
1358 route_free_fn(a, i, "GET")
1359}
1360#[proc_macro_attribute]
1362#[allow(non_snake_case)]
1363pub fn Post(a: TokenStream, i: TokenStream) -> TokenStream {
1364 route_free_fn(a, i, "POST")
1365}
1366#[proc_macro_attribute]
1368#[allow(non_snake_case)]
1369pub fn Put(a: TokenStream, i: TokenStream) -> TokenStream {
1370 route_free_fn(a, i, "PUT")
1371}
1372#[proc_macro_attribute]
1374#[allow(non_snake_case)]
1375pub fn Delete(a: TokenStream, i: TokenStream) -> TokenStream {
1376 route_free_fn(a, i, "DELETE")
1377}
1378#[proc_macro_attribute]
1380#[allow(non_snake_case)]
1381pub fn Patch(a: TokenStream, i: TokenStream) -> TokenStream {
1382 route_free_fn(a, i, "PATCH")
1383}
1384
1385#[proc_macro_attribute]
1389#[allow(non_snake_case)]
1390pub fn CacheTTL(_attr: TokenStream, item: TokenStream) -> TokenStream {
1391 item
1392}
1393
1394#[proc_macro_attribute]
1398#[allow(non_snake_case)]
1399pub fn AuditLog(_attr: TokenStream, item: TokenStream) -> TokenStream {
1400 item
1401}
1402
1403#[proc_macro_attribute]
1406#[allow(non_snake_case)]
1407pub fn Timeout(_attr: TokenStream, item: TokenStream) -> TokenStream {
1408 item
1409}
1410
1411#[proc_macro_attribute]
1415#[allow(non_snake_case)]
1416pub fn Version(_attr: TokenStream, item: TokenStream) -> TokenStream {
1417 item
1418}
1419
1420#[proc_macro_attribute]
1424#[allow(non_snake_case)]
1425pub fn Deprecated(_attr: TokenStream, item: TokenStream) -> TokenStream {
1426 item
1427}
1428
1429#[proc_macro_attribute]
1433#[allow(non_snake_case)]
1434pub fn Transactional(_attr: TokenStream, item: TokenStream) -> TokenStream {
1435 item
1436}
1437
1438#[proc_macro_attribute]
1442#[allow(non_snake_case)]
1443pub fn Idempotent(_attr: TokenStream, item: TokenStream) -> TokenStream {
1444 item
1445}
1446
1447#[proc_macro_attribute]
1451#[allow(non_snake_case)]
1452pub fn RequirePolicies(_attr: TokenStream, item: TokenStream) -> TokenStream {
1453 item
1454}
1455
1456#[proc_macro_attribute]
1459#[allow(non_snake_case)]
1460pub fn EventPattern(_attr: TokenStream, item: TokenStream) -> TokenStream {
1461 item
1462}
1463
1464#[proc_macro_attribute]
1469#[allow(non_snake_case)]
1470pub fn EventConsumer(_attr: TokenStream, item: TokenStream) -> TokenStream {
1471 let mut imp = parse_macro_input!(item as ItemImpl);
1472 let self_ty = (*imp.self_ty).clone();
1473 let consumer_name = match &self_ty {
1474 Type::Path(tp) => tp
1475 .path
1476 .segments
1477 .last()
1478 .map(|s| s.ident.to_string())
1479 .unwrap_or_default(),
1480 _ => String::new(),
1481 };
1482
1483 let mut registrations: Vec<TokenStream2> = Vec::new();
1484 let mut errors: Vec<syn::Error> = Vec::new();
1485
1486 for item in imp.items.iter_mut() {
1487 let ImplItem::Fn(m) = item else { continue };
1488 let mut topic: Option<LitStr> = None;
1489 let mut keep: Vec<Attribute> = Vec::with_capacity(m.attrs.len());
1490 for a in m.attrs.drain(..) {
1491 let id = a
1492 .path()
1493 .get_ident()
1494 .map(|i| i.to_string())
1495 .unwrap_or_default();
1496 if id == "EventPattern" {
1497 match a.parse_args::<LitStr>() {
1498 Ok(t) => topic = Some(t),
1499 Err(e) => errors.push(e),
1500 }
1501 } else {
1502 keep.push(a);
1503 }
1504 }
1505 m.attrs = keep;
1506 let Some(topic) = topic else { continue };
1507
1508 let fn_name = m.sig.ident.clone();
1509 let thunk = format_ident!("__arcly_event_{}_{}", consumer_name, fn_name);
1510 let desc = format_ident!("__ARCLY_EVENT_DESC_{}_{}", consumer_name, fn_name);
1511 let consumer_lit = LitStr::new(&consumer_name, Span::call_site());
1512
1513 registrations.push(quote! {
1514 #[allow(non_snake_case)]
1515 fn #thunk(ctx: ::arcly_http::__macro_support::EventContext)
1516 -> ::arcly_http::futures::future::BoxFuture<'static, ::core::result::Result<(), ::arcly_http::__macro_support::EventError>>
1517 {
1518 ::arcly_http::futures::FutureExt::boxed(async move {
1521 <#self_ty>::#fn_name(ctx).await.map_err(::core::convert::Into::into)
1522 })
1523 }
1524
1525 #[allow(non_upper_case_globals)]
1526 static #desc: ::arcly_http::__macro_support::EventHandlerDescriptor =
1527 ::arcly_http::__macro_support::EventHandlerDescriptor {
1528 topic: #topic,
1529 consumer: #consumer_lit,
1530 handler: #thunk,
1531 };
1532
1533 ::arcly_http::inventory::submit! { &#desc }
1534 });
1535 }
1536
1537 if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1538 a.combine(b);
1539 a
1540 }) {
1541 return err.to_compile_error().into();
1542 }
1543
1544 quote! {
1545 #imp
1546 #( #registrations )*
1547 }
1548 .into()
1549}
1550
1551#[proc_macro_attribute]
1555#[allow(non_snake_case)]
1556pub fn MaskFields(_attr: TokenStream, item: TokenStream) -> TokenStream {
1557 item
1558}
1559
1560#[proc_macro_attribute]
1568#[allow(non_snake_case)]
1569pub fn Multipart(_attr: TokenStream, item: TokenStream) -> TokenStream {
1570 item
1571}
1572
1573#[proc_macro_attribute]
1575#[allow(non_snake_case)]
1576pub fn CacheKey(_attr: TokenStream, item: TokenStream) -> TokenStream {
1577 item
1578}
1579
1580#[proc_macro_attribute]
1582#[allow(non_snake_case)]
1583pub fn UseInterceptors(_attr: TokenStream, item: TokenStream) -> TokenStream {
1584 item
1590}
1591
1592fn route_free_fn(attr: TokenStream, item: TokenStream, method: &'static str) -> TokenStream {
1593 let args = parse_macro_input!(attr as RouteArgs);
1594 let mut f = parse_macro_input!(item as ItemFn);
1595
1596 let mut interceptors: Vec<Path> = Vec::new();
1599 let mut keep_attrs: Vec<Attribute> = Vec::with_capacity(f.attrs.len());
1600 for a in f.attrs.drain(..) {
1601 let id = a
1602 .path()
1603 .get_ident()
1604 .map(|i| i.to_string())
1605 .unwrap_or_default();
1606 if id == "UseInterceptors" {
1607 match a.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated) {
1608 Ok(list) => interceptors.extend(list),
1609 Err(e) => return e.to_compile_error().into(),
1610 }
1611 } else {
1612 keep_attrs.push(a);
1613 }
1614 }
1615 f.attrs = keep_attrs;
1616
1617 let (cache_ttl_secs, cache_key): (u64, String) = match harvest_cache_attrs(&mut f.attrs) {
1618 Ok(p) => p,
1619 Err(e) => return e.to_compile_error().into(),
1620 };
1621
1622 let path_lit = args.path.clone();
1623 let full_path = path_lit.value();
1624
1625 let fn_name = f.sig.ident.clone();
1626 let (thunk_name, desc_name, spec_name) = route_idents("", &fn_name.to_string());
1629 let method_ident = Ident::new(method, Span::call_site());
1630
1631 let doc = collect_doc_comments(&f.attrs);
1632
1633 let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1634 let mut call_args: Vec<TokenStream2> = Vec::new();
1635 let mut errors: Vec<syn::Error> = Vec::new();
1636 let mut spec_params: Vec<TokenStream2> = Vec::new();
1637 let mut has_body = false;
1638 let mut body_ty: Option<Type> = None;
1639 let mut query_ty: Option<Type> = None;
1640
1641 for (i, input) in f.sig.inputs.iter_mut().enumerate() {
1642 let FnArg::Typed(pt) = input else {
1643 errors.push(syn::Error::new(input.span(), "handler must not take self"));
1644 continue;
1645 };
1646 let var = format_ident!("__arg_{i}");
1647 match classify_arg(pt) {
1648 Ok((kind, ty)) => {
1649 let stmt = emit_extractor(
1650 &kind,
1651 &ty,
1652 &var,
1653 &mut spec_params,
1654 &mut has_body,
1655 &mut body_ty,
1656 &mut query_ty,
1657 );
1658 extract_stmts.push(stmt);
1659 call_args.push(quote! { #var });
1660 }
1661 Err(e) => errors.push(e),
1662 }
1663 pt.attrs.retain(|a| {
1664 let id = a
1665 .path()
1666 .get_ident()
1667 .map(|i| i.to_string())
1668 .unwrap_or_default();
1669 !matches!(id.as_str(), "Param" | "Query" | "Body" | "Header")
1670 });
1671 }
1672
1673 if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1674 a.combine(b);
1675 a
1676 }) {
1677 return err.to_compile_error().into();
1678 }
1679
1680 let guard_stmts: Vec<TokenStream2> = args
1681 .guards
1682 .iter()
1683 .map(|g| {
1684 quote! {
1685 <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
1686 }
1687 })
1688 .collect();
1689
1690 let inner = quote! {
1691 let __run = async move {
1692 #( #guard_stmts )*
1693 #( #extract_stmts )*
1694 ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1695 #fn_name( #( #call_args ),* ).await
1696 )
1697 };
1698 match __run.await {
1699 ::core::result::Result::Ok(v) => {
1700 ::arcly_http::__axum::response::IntoResponse::into_response(v)
1701 }
1702 ::core::result::Result::Err(e) => {
1703 ::arcly_http::__axum::response::IntoResponse::into_response(e)
1704 }
1705 }
1706 };
1707
1708 let thunk_body = wrap_interceptors(inner, &interceptors);
1709
1710 let fn_str = fn_name.to_string();
1711 let summary_str = args
1712 .summary
1713 .as_ref()
1714 .map(|s| s.value())
1715 .unwrap_or_else(|| fn_str.clone());
1716 let operation_id = args
1717 .operation_id
1718 .as_ref()
1719 .map(|s| s.value())
1720 .unwrap_or_else(|| fn_str.clone());
1721 let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1722 let deprecated = args.deprecated;
1723 let tag_lits: Vec<TokenStream2> = args.tags.iter().map(|t| quote!(#t)).collect();
1724 let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1725 let status_expr = match &args.status {
1726 Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1727 None => quote! { ::core::option::Option::None },
1728 };
1729 let body_schema_expr = schema_expr(&body_ty);
1730 let consumes_lit = LitStr::new("application/json", Span::call_site());
1733 let query_schema_expr = schema_expr(&query_ty);
1734 let response_schema_expr = schema_expr(&extract_response_ty(&f.sig.output));
1735 let full_path_lit = LitStr::new(&full_path, Span::call_site());
1736
1737 quote! {
1738 #f
1739
1740 fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1741 -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1742 {
1743 ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1744 }
1745
1746 #[allow(non_upper_case_globals)]
1747 static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1748 ::arcly_http::__macro_support::RouteSpec {
1749 summary: #summary_str,
1750 description: #description_str,
1751 operation_id: #operation_id,
1752 tags: &[ #( #tag_lits ),* ],
1753 security: &[ #( #sec_lits ),* ],
1754 status_code: #status_expr,
1755 deprecated: #deprecated,
1756 params: &[ #( #spec_params ),* ],
1757 has_body: #has_body,
1758 body_schema: #body_schema_expr,
1759 consumes: #consumes_lit,
1760 query_schema: #query_schema_expr,
1761 response_schema: #response_schema_expr,
1762 cache_ttl_secs: #cache_ttl_secs,
1763 cache_key: #cache_key,
1764 api_version: "",
1765 sunset: "",
1766 idempotent_ttl_secs: 0,
1767 policies: &[],
1768 audit_action: "",
1769 audit_resource: "",
1770 timeout_ms: 0,
1771 transactional: false,
1772 mask_fields: &[],
1773 };
1774
1775 #[allow(non_upper_case_globals)]
1776 static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1777 ::arcly_http::__macro_support::RouteDescriptor {
1778 method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1779 path: #full_path_lit,
1780 handler: #thunk_name,
1781 spec: &#spec_name,
1782 controller: "",
1783 };
1784
1785 ::arcly_http::inventory::submit! {
1786 &#desc_name
1787 }
1788 }
1789 .into()
1790}
1791
1792enum ParamKind {
1796 Param(LitStr),
1797 Query,
1798 Body,
1799 Header(LitStr),
1800 Ctx,
1801 FromContext,
1802}
1803
1804fn classify_arg(arg: &PatType) -> syn::Result<(ParamKind, Type)> {
1805 for attr in &arg.attrs {
1806 let ident = attr
1807 .path()
1808 .get_ident()
1809 .map(|i| i.to_string())
1810 .unwrap_or_default();
1811 match ident.as_str() {
1812 "Param" => {
1813 let name: LitStr = attr.parse_args()?;
1814 return Ok((ParamKind::Param(name), (*arg.ty).clone()));
1815 }
1816 "Query" => return Ok((ParamKind::Query, (*arg.ty).clone())),
1817 "Body" => return Ok((ParamKind::Body, (*arg.ty).clone())),
1818 "Header" => {
1819 let name: LitStr = attr.parse_args()?;
1820 return Ok((ParamKind::Header(name), (*arg.ty).clone()));
1821 }
1822 _ => {}
1823 }
1824 }
1825 let ty_ref = &*arg.ty;
1826 let ty_str = quote!(#ty_ref).to_string();
1827 let ty = (*arg.ty).clone();
1828 let kind = if ty_str.contains("RequestContext") {
1829 ParamKind::Ctx
1830 } else {
1831 ParamKind::FromContext
1832 };
1833 Ok((kind, ty))
1834}
1835
1836fn emit_extractor(
1837 kind: &ParamKind,
1838 ty: &Type,
1839 var: &Ident,
1840 spec_params: &mut Vec<TokenStream2>,
1841 has_body: &mut bool,
1842 body_ty: &mut Option<Type>,
1843 query_ty: &mut Option<Type>,
1844) -> TokenStream2 {
1845 match kind {
1846 ParamKind::Param(name) => {
1847 spec_params.push(quote! {
1848 ::arcly_http::__macro_support::ParamSpec {
1849 name: #name,
1850 loc: ::arcly_http::__macro_support::ParamLoc::Path,
1851 required: true,
1852 schema: || ::arcly_http::__schema_for::<#ty>(),
1853 }
1854 });
1855 quote! { let #var: #ty = ::arcly_http::__macro_support::extract_param(&ctx, #name)?; }
1856 }
1857 ParamKind::Query => {
1858 *query_ty = Some(ty.clone());
1859 quote! { let #var: #ty = ::arcly_http::__macro_support::extract_query_validated(&ctx)?; }
1860 }
1861 ParamKind::Body => {
1862 *has_body = true;
1863 *body_ty = Some(ty.clone());
1864 quote! { let #var: #ty = ::arcly_http::__macro_support::extract_body_validated(&ctx)?; }
1865 }
1866 ParamKind::Header(name) => {
1867 spec_params.push(quote! {
1868 ::arcly_http::__macro_support::ParamSpec {
1869 name: #name,
1870 loc: ::arcly_http::__macro_support::ParamLoc::Header,
1871 required: true,
1872 schema: || ::arcly_http::__schema_for::<#ty>(),
1873 }
1874 });
1875 quote! { let #var: #ty = ::arcly_http::__macro_support::extract_header(&ctx, #name)?.to_owned(); }
1876 }
1877 ParamKind::Ctx => quote! { let #var: #ty = ctx.clone(); },
1878 ParamKind::FromContext => quote! { let #var: #ty = <#ty>::from_ctx(&ctx); },
1879 }
1880}
1881
1882fn extract_response_ty(ret: &ReturnType) -> Option<Type> {
1886 let ty = match ret {
1887 ReturnType::Default => return None,
1888 ReturnType::Type(_, ty) => &**ty,
1889 };
1890 inner_payload_ty(ty).cloned()
1891}
1892
1893fn inner_payload_ty(ty: &Type) -> Option<&Type> {
1894 let Type::Path(tp) = ty else { return None };
1895 let seg = tp.path.segments.last()?;
1896 match seg.ident.to_string().as_str() {
1897 "Json" | "Created" | "Accepted" => first_generic(&seg.arguments),
1898 "Result" => {
1899 let ok = first_generic(&seg.arguments)?;
1900 inner_payload_ty(ok)
1901 }
1902 "NoContent" => None,
1903 _ => None,
1904 }
1905}
1906
1907fn first_generic(args: &PathArguments) -> Option<&Type> {
1908 let PathArguments::AngleBracketed(ab) = args else {
1909 return None;
1910 };
1911 ab.args.iter().find_map(|a| match a {
1912 GenericArgument::Type(t) => Some(t),
1913 _ => None,
1914 })
1915}
1916
1917fn collect_doc_comments(attrs: &[Attribute]) -> String {
1918 let mut out = String::new();
1919 for a in attrs {
1920 if !a.path().is_ident("doc") {
1921 continue;
1922 }
1923 if let Meta::NameValue(nv) = &a.meta {
1924 if let Expr::Lit(ExprLit {
1925 lit: Lit::Str(s), ..
1926 }) = &nv.value
1927 {
1928 let line = s.value();
1929 if !out.is_empty() {
1930 out.push('\n');
1931 }
1932 out.push_str(line.trim_start());
1933 }
1934 }
1935 }
1936 out
1937}
1938
1939struct GatewayArgs {
1950 path: LitStr,
1951 #[allow(dead_code)]
1952 tags: Vec<LitStr>,
1953}
1954
1955impl Parse for GatewayArgs {
1956 fn parse(input: ParseStream) -> syn::Result<Self> {
1957 let path: LitStr = input.parse()?;
1958 let mut tags: Vec<LitStr> = vec![];
1959 if input.peek(Token![,]) {
1960 let _: Token![,] = input.parse()?;
1961 if !input.is_empty() {
1962 let key: Ident = input.parse()?;
1963 if key != "tags" {
1964 return Err(syn::Error::new(key.span(), "expected `tags(...)`"));
1965 }
1966 let content;
1967 syn::parenthesized!(content in input);
1968 let list: Punctuated<LitStr, Token![,]> =
1969 content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
1970 tags.extend(list);
1971 }
1972 }
1973 Ok(Self { path, tags })
1974 }
1975}
1976
1977#[proc_macro_attribute]
1983#[allow(non_snake_case)]
1984pub fn Gateway(attr: TokenStream, item: TokenStream) -> TokenStream {
1985 match syn::parse::<ItemImpl>(item) {
1986 Ok(imp) => gateway_on_impl(attr, imp),
1987 Err(_) => syn::Error::new(
1988 Span::call_site(),
1989 "#[Gateway(\"/path\")] must be placed on the gateway's `impl` block \
1990 (the struct uses #[Injectable]; lifecycle goes in `impl ArclyGateway`)",
1991 )
1992 .to_compile_error()
1993 .into(),
1994 }
1995}
1996
1997#[proc_macro_attribute]
2000#[allow(non_snake_case)]
2001pub fn Subscribe(_attr: TokenStream, item: TokenStream) -> TokenStream {
2002 item
2003}
2004
2005fn gateway_on_impl(attr: TokenStream, mut imp: ItemImpl) -> TokenStream {
2006 let GatewayArgs { path, .. } = parse_macro_input!(attr as GatewayArgs);
2007 let self_ty = (*imp.self_ty).clone();
2008 let name = match &self_ty {
2009 Type::Path(tp) => tp
2010 .path
2011 .segments
2012 .last()
2013 .map(|s| s.ident.to_string())
2014 .unwrap_or_default(),
2015 _ => String::new(),
2016 };
2017
2018 let mut dispatch_inserts: Vec<TokenStream2> = Vec::new();
2019 let mut errors: Vec<syn::Error> = Vec::new();
2020
2021 for item in imp.items.iter_mut() {
2022 let ImplItem::Fn(m) = item else { continue };
2023
2024 let sub_idx = m.attrs.iter().position(|a| {
2026 a.path()
2027 .get_ident()
2028 .map(|i| i.to_string())
2029 .unwrap_or_default()
2030 == "Subscribe"
2031 });
2032 let Some(idx) = sub_idx else { continue };
2033
2034 let event: LitStr = match m.attrs[idx].parse_args() {
2035 Ok(e) => e,
2036 Err(e) => {
2037 errors.push(e);
2038 continue;
2039 }
2040 };
2041 m.attrs.remove(idx); let fn_name = m.sig.ident.clone();
2044 match build_subscribe_insert(&self_ty, m, &event, &fn_name) {
2045 Ok(ts) => dispatch_inserts.push(ts),
2046 Err(e) => errors.push(e),
2047 }
2048 }
2049
2050 if let Some(err) = errors.into_iter().reduce(|mut a, b| {
2051 a.combine(b);
2052 a
2053 }) {
2054 return err.to_compile_error().into();
2055 }
2056
2057 let path_lit = LitStr::new(&path.value(), Span::call_site());
2058 let name_lit = LitStr::new(&name, Span::call_site());
2059 let build_ident = format_ident!("__arcly_build_gateway_{}", name.to_uppercase());
2060 let desc_ident = format_ident!("__ARCLY_GATEWAY_{}", name.to_uppercase());
2061
2062 quote! {
2063 #imp
2064
2065 #[doc(hidden)]
2066 #[allow(non_snake_case)]
2067 fn #build_ident(__container: &'static ::arcly_http::__macro_support::FrozenDiContainer)
2068 -> &'static ::arcly_http::__macro_support::GatewayRuntime
2069 {
2070 let __gw: &'static #self_ty = ::std::boxed::Box::leak(::std::boxed::Box::new(
2073 <#self_ty>::__arcly_build(&__container.resolver())
2074 ));
2075
2076 let mut __dispatch: ::std::collections::HashMap<
2077 &'static str,
2078 ::arcly_http::__macro_support::MessageHandler,
2079 > = ::std::collections::HashMap::new();
2080 #( #dispatch_inserts )*
2081
2082 ::std::boxed::Box::leak(::std::boxed::Box::new(
2083 ::arcly_http::__macro_support::GatewayRuntime {
2084 path: #path_lit,
2085 on_connect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
2086 let __gw = __gw;
2087 ::arcly_http::futures::FutureExt::boxed(async move {
2088 <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_connect(__gw, __c).await
2089 })
2090 }),
2091 on_disconnect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
2092 let __gw = __gw;
2093 ::arcly_http::futures::FutureExt::boxed(async move {
2094 <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_disconnect(__gw, __c).await
2095 })
2096 }),
2097 dispatch: __dispatch,
2098 }
2099 ))
2100 }
2101
2102 #[allow(non_upper_case_globals)]
2103 static #desc_ident: ::arcly_http::__macro_support::GatewayDescriptor =
2104 ::arcly_http::__macro_support::GatewayDescriptor {
2105 name: #name_lit,
2106 path: #path_lit,
2107 build: #build_ident,
2108 };
2109
2110 ::arcly_http::inventory::submit! { &#desc_ident }
2111 }
2112 .into()
2113}
2114
2115fn build_subscribe_insert(
2120 self_ty: &Type,
2121 m: &syn::ImplItemFn,
2122 event: &LitStr,
2123 fn_name: &Ident,
2124) -> syn::Result<TokenStream2> {
2125 let mut extract_stmts: Vec<TokenStream2> = Vec::new();
2126 let mut call_args: Vec<TokenStream2> = Vec::new();
2127
2128 for (i, input) in m.sig.inputs.iter().enumerate() {
2129 let pt = match input {
2130 FnArg::Receiver(_) => continue, FnArg::Typed(pt) => pt,
2132 };
2133 let ty = (*pt.ty).clone();
2134 let var = format_ident!("__sub_arg_{i}");
2135
2136 if type_last_ident_is(&ty, "WsClient") {
2137 extract_stmts.push(
2138 quote! { let #var: ::arcly_http::__macro_support::WsClient = __client.clone(); },
2139 );
2140 call_args.push(quote! { #var });
2141 } else if let Some(inner) = json_inner_ty(&ty) {
2142 extract_stmts.push(quote! {
2143 let #var: ::arcly_http::__macro_support::Json<#inner> = ::arcly_http::__macro_support::Json(
2144 ::arcly_http::serde_json::from_str::<#inner>(&__data)
2145 .map_err(|_| ::arcly_http::__macro_support::Error::BadRequest("invalid websocket payload"))?
2146 );
2147 });
2148 call_args.push(quote! { #var });
2149 } else {
2150 return Err(syn::Error::new(
2151 pt.span(),
2152 "#[Subscribe] handler params must be `WsClient` or `Json<T>`",
2153 ));
2154 }
2155 }
2156
2157 Ok(quote! {
2158 {
2159 let __gw = __gw;
2160 let __handler: ::arcly_http::__macro_support::MessageHandler = ::std::sync::Arc::new(
2161 move |__client: ::arcly_http::__macro_support::WsClient, __data: ::std::sync::Arc<str>| {
2162 let __gw = __gw;
2163 ::arcly_http::futures::FutureExt::boxed(async move {
2164 #( #extract_stmts )*
2165 <#self_ty>::#fn_name(__gw, #( #call_args ),*).await
2166 })
2167 }
2168 );
2169 __dispatch.insert(#event, __handler);
2170 }
2171 })
2172}
2173
2174fn type_last_ident_is(ty: &Type, name: &str) -> bool {
2175 matches!(ty, Type::Path(tp)
2176 if tp.path.segments.last().map(|s| s.ident == name).unwrap_or(false))
2177}
2178
2179fn json_inner_ty(ty: &Type) -> Option<Type> {
2180 let Type::Path(tp) = ty else { return None };
2181 let seg = tp.path.segments.last()?;
2182 if seg.ident != "Json" {
2183 return None;
2184 }
2185 first_generic(&seg.arguments).cloned()
2186}
2187
2188struct BreakerArgs {
2192 threshold: u32,
2193 cooldown_millis: u64,
2194}
2195
2196impl Parse for BreakerArgs {
2197 fn parse(input: ParseStream) -> syn::Result<Self> {
2198 let mut threshold: Option<u32> = None;
2199 let mut cooldown_millis: Option<u64> = None;
2200 while !input.is_empty() {
2201 let key: Ident = input.parse()?;
2202 let _: Token![=] = input.parse()?;
2203 match key.to_string().as_str() {
2204 "threshold" => {
2205 let n: LitInt = input.parse()?;
2206 threshold = Some(n.base10_parse()?);
2207 }
2208 "cooldown" => {
2209 let s: LitStr = input.parse()?;
2210 cooldown_millis = Some(parse_duration_ms(&s)?);
2211 }
2212 other => {
2213 return Err(syn::Error::new(
2214 key.span(),
2215 format!("unknown circuit_breaker key `{other}`"),
2216 ))
2217 }
2218 }
2219 let _ = input.parse::<Token![,]>();
2220 }
2221 Ok(Self {
2222 threshold: threshold
2223 .ok_or_else(|| syn::Error::new(input.span(), "missing `threshold = N`"))?,
2224 cooldown_millis: cooldown_millis
2225 .ok_or_else(|| syn::Error::new(input.span(), "missing `cooldown = \"…\"`"))?,
2226 })
2227 }
2228}
2229
2230fn parse_duration_ms(s: &LitStr) -> syn::Result<u64> {
2231 let raw = s.value();
2232 let r = raw.trim();
2233 let (num_s, unit) = match r.rfind(|c: char| c.is_ascii_digit()) {
2234 Some(i) => (&r[..=i], &r[i + 1..]),
2235 None => return Err(syn::Error::new(s.span(), "invalid duration")),
2236 };
2237 let n: u64 = num_s
2238 .parse()
2239 .map_err(|_| syn::Error::new(s.span(), "invalid duration number"))?;
2240 let mult = match unit.trim() {
2241 "ms" => 1,
2242 "s" | "" => 1_000,
2243 "m" => 60_000,
2244 "h" => 3_600_000,
2245 other => {
2246 return Err(syn::Error::new(
2247 s.span(),
2248 format!("unknown duration unit `{other}`"),
2249 ))
2250 }
2251 };
2252 Ok(n * mult)
2253}
2254
2255#[proc_macro_attribute]
2260pub fn circuit_breaker(attr: TokenStream, item: TokenStream) -> TokenStream {
2261 let args = parse_macro_input!(attr as BreakerArgs);
2262 let mut f = parse_macro_input!(item as syn::ImplItemFn);
2263
2264 let threshold = args.threshold;
2265 let cooldown_ms = args.cooldown_millis;
2266
2267 let breaker_name = format_ident!("__BREAKER_{}", f.sig.ident.to_string().to_uppercase());
2268 let breaker_label = f.sig.ident.to_string();
2269
2270 let original_body = f.block.clone();
2272 let new_block: Block = parse_quote! {{
2273 static #breaker_name: ::arcly_http::__macro_support::CircuitBreaker =
2274 ::arcly_http::__macro_support::CircuitBreaker::const_named(
2275 #breaker_label, #threshold, #cooldown_ms,
2276 );
2277 match #breaker_name.execute(|| async move #original_body).await {
2278 ::core::result::Result::Ok(inner) => inner,
2279 ::core::result::Result::Err(_open) => ::core::result::Result::Err(
2280 <_ as ::core::convert::From<::arcly_http::__macro_support::BreakerOpen>>::from(_open),
2281 ),
2282 }
2283 }};
2284 f.block = new_block;
2285
2286 quote! { #f }.into()
2287}
2288
2289struct EncryptFieldsArgs {
2295 key: LitStr,
2296 fields: Vec<LitStr>,
2297}
2298
2299impl Parse for EncryptFieldsArgs {
2300 fn parse(input: ParseStream) -> syn::Result<Self> {
2301 let mut key: Option<LitStr> = None;
2302 let mut fields: Vec<LitStr> = Vec::new();
2303 while !input.is_empty() {
2304 let ident: Ident = input.parse()?;
2305 match ident.to_string().as_str() {
2306 "key" => {
2307 input.parse::<Token![=]>()?;
2308 key = Some(input.parse()?);
2309 }
2310 "fields" => {
2311 let inner;
2312 syn::parenthesized!(inner in input);
2313 let lits: Punctuated<LitStr, Token![,]> =
2314 inner.parse_terminated(|p: ParseStream| p.parse::<LitStr>(), Token![,])?;
2315 fields.extend(lits);
2316 }
2317 other => {
2318 return Err(syn::Error::new(
2319 ident.span(),
2320 format!(
2321 "unknown EncryptFields argument `{other}` (expected `key` or `fields`)"
2322 ),
2323 ))
2324 }
2325 }
2326 if input.peek(Token![,]) {
2327 input.parse::<Token![,]>()?;
2328 }
2329 }
2330 let key = key.ok_or_else(|| {
2331 syn::Error::new(Span::call_site(), "EncryptFields requires `key = \"...\"`")
2332 })?;
2333 if fields.is_empty() {
2334 return Err(syn::Error::new(
2335 Span::call_site(),
2336 "EncryptFields requires at least one entry in `fields(...)`",
2337 ));
2338 }
2339 Ok(Self { key, fields })
2340 }
2341}
2342
2343#[proc_macro_attribute]
2350#[allow(non_snake_case)]
2351pub fn EncryptFields(attr: TokenStream, item: TokenStream) -> TokenStream {
2352 let args = parse_macro_input!(attr as EncryptFieldsArgs);
2353 let st = parse_macro_input!(item as ItemStruct);
2354 let name = &st.ident;
2355 let (impl_g, ty_g, where_c) = st.generics.split_for_impl();
2356 let key = &args.key;
2357 let fields = &args.fields;
2358
2359 quote! {
2360 #st
2361
2362 impl #impl_g ::arcly_http::__macro_support::EncryptRecord for #name #ty_g #where_c {
2363 const ENCRYPT_FIELDS: &'static [&'static str] = &[ #( #fields ),* ];
2364 const KEY_ID: &'static str = #key;
2365 }
2366 }
2367 .into()
2368}