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