1use proc_macro::TokenStream;
18use quote::quote;
19use std::collections::HashSet;
20use syn::{
21 parse_macro_input, FnArg, GenericArgument, ItemFn, LitStr, PathArguments, ReturnType, Type,
22};
23
24#[proc_macro_attribute]
38pub fn schema(_attr: TokenStream, item: TokenStream) -> TokenStream {
39 let input = parse_macro_input!(item as syn::Item);
40
41 let (ident, generics) = match &input {
42 syn::Item::Struct(s) => (&s.ident, &s.generics),
43 syn::Item::Enum(e) => (&e.ident, &e.generics),
44 _ => {
45 return syn::Error::new_spanned(
46 &input,
47 "#[rustapi_rs::schema] can only be used on structs or enums",
48 )
49 .to_compile_error()
50 .into();
51 }
52 };
53
54 if !generics.params.is_empty() {
55 return syn::Error::new_spanned(
56 generics,
57 "#[rustapi_rs::schema] does not support generic types",
58 )
59 .to_compile_error()
60 .into();
61 }
62
63 let registrar_ident = syn::Ident::new(
64 &format!("__RUSTAPI_AUTO_SCHEMA_{}", ident),
65 proc_macro2::Span::call_site(),
66 );
67
68 let expanded = quote! {
69 #input
70
71 #[allow(non_upper_case_globals)]
72 #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_SCHEMAS)]
73 #[linkme(crate = ::rustapi_rs::__private::linkme)]
74 static #registrar_ident: fn(&mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) =
75 |spec: &mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec| {
76 spec.register_in_place::<#ident>();
77 };
78 };
79
80 debug_output("schema", &expanded);
81 expanded.into()
82}
83
84fn extract_schema_types(ty: &Type, out: &mut Vec<Type>, allow_leaf: bool) {
85 match ty {
86 Type::Reference(r) => extract_schema_types(&r.elem, out, allow_leaf),
87 Type::Path(tp) => {
88 let Some(seg) = tp.path.segments.last() else {
89 return;
90 };
91
92 let ident = seg.ident.to_string();
93
94 let unwrap_first_generic = |out: &mut Vec<Type>| {
95 if let PathArguments::AngleBracketed(args) = &seg.arguments {
96 if let Some(GenericArgument::Type(inner)) = args.args.first() {
97 extract_schema_types(inner, out, true);
98 }
99 }
100 };
101
102 match ident.as_str() {
103 "Json" | "ValidatedJson" | "Created" => {
105 unwrap_first_generic(out);
106 }
107 "WithStatus" => {
109 if let PathArguments::AngleBracketed(args) = &seg.arguments {
110 if let Some(GenericArgument::Type(inner)) = args.args.first() {
111 extract_schema_types(inner, out, true);
112 }
113 }
114 }
115 "Option" | "Result" => {
117 if let PathArguments::AngleBracketed(args) = &seg.arguments {
118 if let Some(GenericArgument::Type(inner)) = args.args.first() {
119 extract_schema_types(inner, out, allow_leaf);
120 }
121 }
122 }
123 _ => {
124 if allow_leaf {
125 out.push(ty.clone());
126 }
127 }
128 }
129 }
130 _ => {}
131 }
132}
133
134fn collect_handler_schema_types(input: &ItemFn) -> Vec<Type> {
135 let mut found: Vec<Type> = Vec::new();
136
137 for arg in &input.sig.inputs {
138 if let FnArg::Typed(pat_ty) = arg {
139 extract_schema_types(&pat_ty.ty, &mut found, false);
140 }
141 }
142
143 if let ReturnType::Type(_, ty) = &input.sig.output {
144 extract_schema_types(ty, &mut found, false);
145 }
146
147 let mut seen = HashSet::<String>::new();
149 found
150 .into_iter()
151 .filter(|t| seen.insert(quote!(#t).to_string()))
152 .collect()
153}
154
155fn is_debug_enabled() -> bool {
157 std::env::var("RUSTAPI_DEBUG")
158 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
159 .unwrap_or(false)
160}
161
162fn debug_output(name: &str, tokens: &proc_macro2::TokenStream) {
164 if is_debug_enabled() {
165 eprintln!("\n=== RUSTAPI_DEBUG: {} ===", name);
166 eprintln!("{}", tokens);
167 eprintln!("=== END {} ===\n", name);
168 }
169}
170
171fn validate_path_syntax(path: &str, span: proc_macro2::Span) -> Result<(), syn::Error> {
175 if !path.starts_with('/') {
177 return Err(syn::Error::new(
178 span,
179 format!("route path must start with '/', got: \"{}\"", path),
180 ));
181 }
182
183 if path.contains("//") {
185 return Err(syn::Error::new(
186 span,
187 format!(
188 "route path contains empty segment (double slash): \"{}\"",
189 path
190 ),
191 ));
192 }
193
194 let mut brace_depth = 0;
196 let mut param_start = None;
197
198 for (i, ch) in path.char_indices() {
199 match ch {
200 '{' => {
201 if brace_depth > 0 {
202 return Err(syn::Error::new(
203 span,
204 format!(
205 "nested braces are not allowed in route path at position {}: \"{}\"",
206 i, path
207 ),
208 ));
209 }
210 brace_depth += 1;
211 param_start = Some(i);
212 }
213 '}' => {
214 if brace_depth == 0 {
215 return Err(syn::Error::new(
216 span,
217 format!(
218 "unmatched closing brace '}}' at position {} in route path: \"{}\"",
219 i, path
220 ),
221 ));
222 }
223 brace_depth -= 1;
224
225 if let Some(start) = param_start {
227 let param_name = &path[start + 1..i];
228 if param_name.is_empty() {
229 return Err(syn::Error::new(
230 span,
231 format!(
232 "empty parameter name '{{}}' at position {} in route path: \"{}\"",
233 start, path
234 ),
235 ));
236 }
237 if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
239 return Err(syn::Error::new(
240 span,
241 format!(
242 "invalid parameter name '{{{}}}' at position {} - parameter names must contain only alphanumeric characters and underscores: \"{}\"",
243 param_name, start, path
244 ),
245 ));
246 }
247 if param_name
249 .chars()
250 .next()
251 .map(|c| c.is_ascii_digit())
252 .unwrap_or(false)
253 {
254 return Err(syn::Error::new(
255 span,
256 format!(
257 "parameter name '{{{}}}' cannot start with a digit at position {}: \"{}\"",
258 param_name, start, path
259 ),
260 ));
261 }
262 }
263 param_start = None;
264 }
265 _ if brace_depth == 0 => {
267 if !ch.is_alphanumeric() && !"-_./*".contains(ch) {
269 return Err(syn::Error::new(
270 span,
271 format!(
272 "invalid character '{}' at position {} in route path: \"{}\"",
273 ch, i, path
274 ),
275 ));
276 }
277 }
278 _ => {}
279 }
280 }
281
282 if brace_depth > 0 {
284 return Err(syn::Error::new(
285 span,
286 format!(
287 "unclosed brace '{{' in route path (missing closing '}}'): \"{}\"",
288 path
289 ),
290 ));
291 }
292
293 Ok(())
294}
295
296#[proc_macro_attribute]
314pub fn main(_attr: TokenStream, item: TokenStream) -> TokenStream {
315 let input = parse_macro_input!(item as ItemFn);
316
317 let attrs = &input.attrs;
318 let vis = &input.vis;
319 let sig = &input.sig;
320 let block = &input.block;
321
322 let expanded = quote! {
323 #(#attrs)*
324 #[::tokio::main]
325 #vis #sig {
326 #block
327 }
328 };
329
330 debug_output("main", &expanded);
331
332 TokenStream::from(expanded)
333}
334
335fn generate_route_handler(method: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
337 let path = parse_macro_input!(attr as LitStr);
338 let input = parse_macro_input!(item as ItemFn);
339
340 let fn_name = &input.sig.ident;
341 let fn_vis = &input.vis;
342 let fn_attrs = &input.attrs;
343 let fn_async = &input.sig.asyncness;
344 let fn_inputs = &input.sig.inputs;
345 let fn_output = &input.sig.output;
346 let fn_block = &input.block;
347 let fn_generics = &input.sig.generics;
348
349 let schema_types = collect_handler_schema_types(&input);
350
351 let path_value = path.value();
352
353 if let Err(err) = validate_path_syntax(&path_value, path.span()) {
355 return err.to_compile_error().into();
356 }
357
358 let route_fn_name = syn::Ident::new(&format!("{}_route", fn_name), fn_name.span());
360 let auto_route_name = syn::Ident::new(&format!("__AUTO_ROUTE_{}", fn_name), fn_name.span());
362
363 let schema_reg_fn_name =
365 syn::Ident::new(&format!("__{}_register_schemas", fn_name), fn_name.span());
366 let auto_schema_name = syn::Ident::new(&format!("__AUTO_SCHEMA_{}", fn_name), fn_name.span());
367
368 let route_helper = match method {
370 "GET" => quote!(::rustapi_rs::get_route),
371 "POST" => quote!(::rustapi_rs::post_route),
372 "PUT" => quote!(::rustapi_rs::put_route),
373 "PATCH" => quote!(::rustapi_rs::patch_route),
374 "DELETE" => quote!(::rustapi_rs::delete_route),
375 _ => quote!(::rustapi_rs::get_route),
376 };
377
378 let mut chained_calls = quote!();
380
381 for attr in fn_attrs {
382 if let Some(ident) = attr.path().segments.last().map(|s| &s.ident) {
385 let ident_str = ident.to_string();
386 if ident_str == "tag" {
387 if let Ok(lit) = attr.parse_args::<LitStr>() {
388 let val = lit.value();
389 chained_calls = quote! { #chained_calls .tag(#val) };
390 }
391 } else if ident_str == "summary" {
392 if let Ok(lit) = attr.parse_args::<LitStr>() {
393 let val = lit.value();
394 chained_calls = quote! { #chained_calls .summary(#val) };
395 }
396 } else if ident_str == "description" {
397 if let Ok(lit) = attr.parse_args::<LitStr>() {
398 let val = lit.value();
399 chained_calls = quote! { #chained_calls .description(#val) };
400 }
401 }
402 }
403 }
404
405 let expanded = quote! {
406 #(#fn_attrs)*
408 #fn_vis #fn_async fn #fn_name #fn_generics (#fn_inputs) #fn_output #fn_block
409
410 #[doc(hidden)]
412 #fn_vis fn #route_fn_name() -> ::rustapi_rs::Route {
413 #route_helper(#path_value, #fn_name)
414 #chained_calls
415 }
416
417 #[doc(hidden)]
419 #[allow(non_upper_case_globals)]
420 #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_ROUTES)]
421 #[linkme(crate = ::rustapi_rs::__private::linkme)]
422 static #auto_route_name: fn() -> ::rustapi_rs::Route = #route_fn_name;
423
424 #[doc(hidden)]
426 #[allow(non_snake_case)]
427 fn #schema_reg_fn_name(spec: &mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) {
428 #( spec.register_in_place::<#schema_types>(); )*
429 }
430
431 #[doc(hidden)]
432 #[allow(non_upper_case_globals)]
433 #[::rustapi_rs::__private::linkme::distributed_slice(::rustapi_rs::__private::AUTO_SCHEMAS)]
434 #[linkme(crate = ::rustapi_rs::__private::linkme)]
435 static #auto_schema_name: fn(&mut ::rustapi_rs::__private::rustapi_openapi::OpenApiSpec) = #schema_reg_fn_name;
436 };
437
438 debug_output(&format!("{} {}", method, path_value), &expanded);
439
440 TokenStream::from(expanded)
441}
442
443#[proc_macro_attribute]
459pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
460 generate_route_handler("GET", attr, item)
461}
462
463#[proc_macro_attribute]
465pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
466 generate_route_handler("POST", attr, item)
467}
468
469#[proc_macro_attribute]
471pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
472 generate_route_handler("PUT", attr, item)
473}
474
475#[proc_macro_attribute]
477pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
478 generate_route_handler("PATCH", attr, item)
479}
480
481#[proc_macro_attribute]
483pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
484 generate_route_handler("DELETE", attr, item)
485}
486
487#[proc_macro_attribute]
503pub fn tag(attr: TokenStream, item: TokenStream) -> TokenStream {
504 let tag = parse_macro_input!(attr as LitStr);
505 let input = parse_macro_input!(item as ItemFn);
506
507 let attrs = &input.attrs;
508 let vis = &input.vis;
509 let sig = &input.sig;
510 let block = &input.block;
511 let tag_value = tag.value();
512
513 let expanded = quote! {
515 #[doc = concat!("**Tag:** ", #tag_value)]
516 #(#attrs)*
517 #vis #sig #block
518 };
519
520 TokenStream::from(expanded)
521}
522
523#[proc_macro_attribute]
535pub fn summary(attr: TokenStream, item: TokenStream) -> TokenStream {
536 let summary = parse_macro_input!(attr as LitStr);
537 let input = parse_macro_input!(item as ItemFn);
538
539 let attrs = &input.attrs;
540 let vis = &input.vis;
541 let sig = &input.sig;
542 let block = &input.block;
543 let summary_value = summary.value();
544
545 let expanded = quote! {
547 #[doc = #summary_value]
548 #(#attrs)*
549 #vis #sig #block
550 };
551
552 TokenStream::from(expanded)
553}
554
555#[proc_macro_attribute]
567pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
568 let desc = parse_macro_input!(attr as LitStr);
569 let input = parse_macro_input!(item as ItemFn);
570
571 let attrs = &input.attrs;
572 let vis = &input.vis;
573 let sig = &input.sig;
574 let block = &input.block;
575 let desc_value = desc.value();
576
577 let expanded = quote! {
579 #[doc = ""]
580 #[doc = #desc_value]
581 #(#attrs)*
582 #vis #sig #block
583 };
584
585 TokenStream::from(expanded)
586}