1extern crate proc_macro;
2
3use proc_macro::TokenStream;
4use quote::{quote, format_ident};
5use syn::{
6 parse::Parse, parse::ParseStream, parse_macro_input, Attribute, FnArg, Ident, ItemFn, LitStr,
7 Pat, Result, ReturnType, Token, Type
8};
9
10
11struct ApiMacroArgs {
14 method: Ident,
15 path: LitStr,
16 responses: Vec<(syn::LitInt, LitStr, Option<Type>)>,
17 output: Option<(Ident, LitStr)>, }
19
20impl Parse for ApiMacroArgs {
21 fn parse(input: ParseStream) -> Result<Self> {
22 let method: Ident = input.parse()?;
23 input.parse::<Token![,]>()?;
24 let path: LitStr = input.parse()?;
25
26 let mut responses: Vec<(syn::LitInt, LitStr, Option<Type>)> = Vec::new();
27 let mut output: Option<(Ident, LitStr)> = None;
28
29 while input.parse::<Option<Token![,]>>()?.is_some() {
31 if input.peek(Ident) {
32 let kw: Ident = input.parse()?;
33 let kw_str = kw.to_string();
34 if kw_str == "responses" {
35 let content;
36 syn::parenthesized!(content in input);
37 while !content.is_empty() {
38 let group;
39 syn::parenthesized!(group in content);
40 let code: syn::LitInt = group.parse()?;
41 group.parse::<Token![,]>()?;
42 let desc: LitStr = group.parse()?;
43 let ty: Option<Type> = if group.parse::<Option<Token![,]>>()?.is_some() {
44 Some(group.parse()?)
45 } else { None };
46 responses.push((code, desc, ty));
47 let _ = content.parse::<Option<Token![,]>>()?;
48 }
49 } else if kw_str == "output" {
50 let content;
51 syn::parenthesized!(content in input);
52 let kind: Ident = content.parse()?; content.parse::<Token![=]>()?;
54 let ty_lit: LitStr = content.parse()?; output = Some((kind, ty_lit));
56 } else {
57 break;
59 }
60 } else {
61 break;
62 }
63 }
64
65 Ok(ApiMacroArgs { method, path, responses, output })
66 }
67}
68
69#[proc_macro_attribute]
70pub fn api(args: TokenStream, input: TokenStream) -> TokenStream {
71 let item_fn = parse_macro_input!(input as ItemFn);
72 let args_parsed = parse_macro_input!(args as ApiMacroArgs);
73
74 let fn_ident = &item_fn.sig.ident;
75 let fn_name_str = fn_ident.to_string();
76 let method_str = args_parsed.method.to_string();
77 let path_str = args_parsed.path.value();
78 let (summary, description) = parse_doc_comments(&item_fn.attrs);
79
80 let mut params_tokens = Vec::new();
82 let mut request_body_token = quote! { None };
83 let mut _needs_header_map: bool = false;
84 let mut _needs_state: bool = false;
85 let mut dto_inventory_tokens: Vec<proc_macro2::TokenStream> = Vec::new();
86
87 let mut arg_prepare_tokens = Vec::new();
89 let mut call_args_tokens = Vec::new();
90 let mut _state_type_tokens: Option<proc_macro2::TokenStream> = None;
91
92 for arg in &item_fn.sig.inputs {
93 if let FnArg::Typed(pat_type) = arg {
94 if let Some(inner_type) = get_inner_type(&pat_type.ty, "Path") {
95 if let Pat::TupleStruct(pat_tuple) = &*pat_type.pat {
96 if let Some(Pat::Ident(inner_pat)) = pat_tuple.elems.first() {
97 let param_name = inner_pat.ident.to_string();
98 let type_name = type_to_string(inner_type);
99 let type_name_s = sanitize_component_name(&type_name);
100 params_tokens.push(quote! {
101 ::service_kit::ApiParameter {
102 name: #param_name,
103 param_in: ::service_kit::ParamIn::Path,
104 description: "", required: true,
106 type_name: #type_name_s,
107 }
108 });
109 let var_ident = &inner_pat.ident;
111 let inner_ty_tokens = quote! { #inner_type };
112 arg_prepare_tokens.push(quote! {
113 let #var_ident: #inner_ty_tokens = match params.get(#param_name).and_then(|v| v.as_str()) {
114 Some(s) => match s.parse::<#inner_ty_tokens>() {
115 Ok(v) => v,
116 Err(_) => return Err(::service_kit::error::Error::SpecError(format!("invalid path param `{}`", #param_name))),
117 },
118 None => return Err(::service_kit::error::Error::SpecError(format!("missing path param `{}`", #param_name))),
119 };
120 let #var_ident = axum::extract::Path::<#inner_ty_tokens>(#var_ident);
121 });
122 call_args_tokens.push(quote! { #var_ident });
123
124 let type_name = type_to_string(inner_type);
126 let type_name_s = sanitize_component_name(&type_name);
127 dto_inventory_tokens.push(quote! {
128 ::service_kit::inventory::submit! {
129 ::service_kit::ApiDtoMetadata {
130 name: #type_name_s,
131 schema_provider: || (
132 #type_name_s.to_string(),
133 <#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
134 ),
135 }
136 }
137 });
138 }
139 } else if let Pat::Ident(pat_ident) = &*pat_type.pat {
140 let param_name = pat_ident.ident.to_string();
142 let type_name = type_to_string(inner_type);
143 let type_name_s = sanitize_component_name(&type_name);
144 params_tokens.push(quote! {
145 ::service_kit::ApiParameter {
146 name: #param_name,
147 param_in: ::service_kit::ParamIn::Path,
148 description: "",
149 required: true,
150 type_name: #type_name_s,
151 }
152 });
153 let var_ident = &pat_ident.ident;
154 let inner_ty_tokens = quote! { #inner_type };
155 arg_prepare_tokens.push(quote! {
156 let #var_ident: #inner_ty_tokens = match params.get(#param_name).and_then(|v| v.as_str()) {
157 Some(s) => match s.parse::<#inner_ty_tokens>() {
158 Ok(v) => v,
159 Err(_) => return Err(::service_kit::error::Error::SpecError(format!("invalid path param `{}`", #param_name))),
160 },
161 None => return Err(::service_kit::error::Error::SpecError(format!("missing path param `{}`", #param_name))),
162 };
163 let #var_ident = axum::extract::Path::<#inner_ty_tokens>(#var_ident);
164 });
165 call_args_tokens.push(quote! { #var_ident });
166
167 let type_name = type_to_string(inner_type);
169 let type_name_s = sanitize_component_name(&type_name);
170 dto_inventory_tokens.push(quote! {
171 ::service_kit::inventory::submit! {
172 ::service_kit::ApiDtoMetadata {
173 name: #type_name_s,
174 schema_provider: || (
175 #type_name_s.to_string(),
176 <#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
177 ),
178 }
179 }
180 });
181 }
182 } else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Query") {
183 let param_name_opt = if let Pat::TupleStruct(pat_tuple) = &*pat_type.pat {
185 pat_tuple
186 .elems
187 .first()
188 .and_then(|p| match p { Pat::Ident(pi) => Some(pi.ident.to_string()), _ => None })
189 } else if let Pat::Ident(pat_ident) = &*pat_type.pat {
190 Some(pat_ident.ident.to_string())
191 } else { None };
192
193 if let Some(param_name) = param_name_opt {
194 let type_name = type_to_string(inner_type);
195 let type_name_s = sanitize_component_name(&type_name);
196 if type_name != "AuthQuery" && !type_name.ends_with("::AuthQuery") {
198 params_tokens.push(quote! {
199 ::service_kit::ApiParameter {
200 name: #param_name,
201 param_in: ::service_kit::ParamIn::Query,
202 description: "", required: true, type_name: #type_name_s,
205 }
206 });
207 }
208 let var_ident = format_ident!("{}", param_name);
210 let inner_ty_tokens = quote! { #inner_type };
211 arg_prepare_tokens.push(quote! {
212 let #var_ident: #inner_ty_tokens = match __raw_query_str.as_deref() {
213 Some(s) if !s.is_empty() => match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>(s) {
214 Ok(v) => v,
215 Err(e) => return Err(::service_kit::error::Error::SerdeUrlencoded(e)),
216 },
217 _ => match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>("") {
218 Ok(v) => v,
219 Err(e) => return Err(::service_kit::error::Error::SerdeUrlencoded(e)),
220 }
221 };
222 let #var_ident = axum::extract::Query::<#inner_ty_tokens>(#var_ident);
223 });
224 call_args_tokens.push(quote! { #var_ident });
225
226 let type_name = type_to_string(inner_type);
228 let type_name_s = sanitize_component_name(&type_name);
229 if type_name != "AuthQuery" && !type_name.ends_with("::AuthQuery") {
230 dto_inventory_tokens.push(quote! {
231 ::service_kit::inventory::submit! {
232 ::service_kit::ApiDtoMetadata {
233 name: #type_name_s,
234 schema_provider: || (
235 #type_name_s.to_string(),
236 <#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
237 ),
238 }
239 }
240 });
241 }
242 }
243 } else if type_to_string(&pat_type.ty) == "axum :: http :: HeaderMap" || type_to_string(&pat_type.ty).ends_with("::HeaderMap") || type_to_string(&pat_type.ty) == "HeaderMap" {
244 _needs_header_map = true;
246 if let Pat::Ident(pat_ident) = &*pat_type.pat {
247 let var_ident = &pat_ident.ident;
248 arg_prepare_tokens.push(quote! {
249 let #var_ident: axum::http::HeaderMap = headers.clone();
250 });
251 call_args_tokens.push(quote! { #var_ident });
252 }
253 } else if let Some(inner_type) = get_inner_type(&pat_type.ty, "State") {
254 _needs_state = true;
256 _state_type_tokens = Some(quote! { #inner_type });
257 let state_var_ident = format_ident!("__state_arg_{}", call_args_tokens.len());
258 arg_prepare_tokens.push(quote! {
259 let #state_var_ident: axum::extract::State<#inner_type> = {
260 let any_arc = state_any.ok_or_else(|| ::service_kit::error::Error::SpecError("missing router state".to_string()))?;
261 let typed = (&*any_arc).downcast_ref::<#inner_type>().ok_or_else(|| ::service_kit::error::Error::SpecError("invalid router state type".to_string()))?;
262 axum::extract::State::<#inner_type>(typed.clone())
263 };
264 });
265 call_args_tokens.push(quote! { #state_var_ident });
266 } else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Json") {
267 if get_inner_type(inner_type, "Vec").is_some() {
269 panic!("Json<Vec<T>> in request body is not allowed. Wrap it in a DTO with a Vec<T> field.");
270 }
271 let type_name = type_to_string(inner_type);
272 let type_name_s = sanitize_component_name(&type_name);
273 request_body_token = quote! {
274 Some(&::service_kit::ApiRequestBody {
275 description: "", required: true,
277 type_name: #type_name_s,
278 })
279 };
280 let inner_ty_tokens = quote! { #inner_type };
282 let json_ident = syn::Ident::new("__json_body", proc_macro2::Span::call_site());
283 arg_prepare_tokens.push(quote! {
284 let __body_json_source: serde_json::Value = match __content_type_opt.as_deref() {
286 Some(ct) if ct.contains("application/json") => {
287 if __body_bytes.is_empty() {
288 serde_json::Value::Null
289 } else {
290 match serde_json::from_slice::<serde_json::Value>(&__body_bytes) {
291 Ok(v) => v,
292 Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
293 }
294 }
295 },
296 _ => serde_json::Value::Null,
297 };
298 let #json_ident: #inner_ty_tokens = if !__body_json_source.is_null() {
299 match serde_json::from_value(__body_json_source) {
300 Ok(v) => v,
301 Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
302 }
303 } else {
304 match serde_json::from_value(params.clone()) {
306 Ok(v) => v,
307 Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
308 }
309 };
310 let #json_ident = axum::Json::<#inner_ty_tokens>(#json_ident);
311 });
312 call_args_tokens.push(quote! { #json_ident });
313
314 let type_name = type_to_string(inner_type);
316 let type_name_s = sanitize_component_name(&type_name);
317 dto_inventory_tokens.push(quote! {
318 ::service_kit::inventory::submit! {
319 ::service_kit::ApiDtoMetadata {
320 name: #type_name_s,
321 schema_provider: || (
322 #type_name_s.to_string(),
323 <#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
324 ),
325 }
326 }
327 });
328
329 let nested_types = collect_concrete_generic_types(inner_type);
331 for (nested_name, nested_ty) in nested_types {
332 let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
333 let nested_ty_tokens = quote! { #nested_ty };
334 dto_inventory_tokens.push(quote! {
335 ::service_kit::inventory::submit! {
336 ::service_kit::ApiDtoMetadata {
337 name: #nested_name_lit,
338 schema_provider: || (
339 #nested_name_lit.to_string(),
340 <#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
341 ),
342 }
343 }
344 });
345 }
346
347 }
349 else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Form") {
351 if get_inner_type(inner_type, "Vec").is_some() {
352 panic!("Form<Vec<T>> in request body is not allowed. Wrap it in a DTO with a Vec<T> field.");
353 }
354 let type_name = type_to_string(inner_type);
355 let type_name_s = sanitize_component_name(&type_name);
356 request_body_token = quote! {
357 Some(&::service_kit::ApiRequestBody {
358 description: "", required: true,
360 type_name: #type_name_s,
361 })
362 };
363 let inner_ty_tokens = quote! { #inner_type };
364 let form_ident = syn::Ident::new("__form_body", proc_macro2::Span::call_site());
365 arg_prepare_tokens.push(quote! {
366 let #form_ident: #inner_ty_tokens = match __content_type_opt.as_deref() {
368 Some(ct) if ct.contains("application/x-www-form-urlencoded") => {
369 let raw = String::from_utf8(__body_bytes.clone()).unwrap_or_default();
370 match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>(&raw) {
371 Ok(v) => v,
372 Err(_e) => {
373 match serde_json::from_slice::<#inner_ty_tokens>(&__body_bytes) {
375 Ok(v) => v,
376 Err(e2) => return Err(::service_kit::error::Error::SerdeJson(e2)),
377 }
378 }
379 }
380 },
381 _ => {
382 match serde_json::from_value(params.clone()) {
384 Ok(v) => v,
385 Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
386 }
387 }
388 };
389 let #form_ident = axum::Form::<#inner_ty_tokens>(#form_ident);
390 });
391 call_args_tokens.push(quote! { #form_ident });
392 let type_name = type_to_string(inner_type);
394 let type_name_s = sanitize_component_name(&type_name);
395 dto_inventory_tokens.push(quote! {
396 ::service_kit::inventory::submit! {
397 ::service_kit::ApiDtoMetadata {
398 name: #type_name_s,
399 schema_provider: || (
400 #type_name_s.to_string(),
401 <#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
402 ),
403 }
404 }
405 });
406 let nested_types = collect_concrete_generic_types(inner_type);
407 for (nested_name, nested_ty) in nested_types {
408 let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
409 let nested_ty_tokens = quote! { #nested_ty };
410 dto_inventory_tokens.push(quote! {
411 ::service_kit::inventory::submit! {
412 ::service_kit::ApiDtoMetadata {
413 name: #nested_name_lit,
414 schema_provider: || (
415 #nested_name_lit.to_string(),
416 <#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
417 ),
418 }
419 }
420 });
421 }
422 }
423 }
424 }
425
426 let mut responses_tokens = Vec::new();
428 let mut response_dto_inventory_tokens: Vec<proc_macro2::TokenStream> = Vec::new();
429 if let ReturnType::Type(_, ty) = &item_fn.sig.output {
430 if let Some(inner_type) = get_inner_type(ty, "Json") {
431 if let Some(vec_inner) = get_inner_type(inner_type, "Vec") {
433 if let Some((ref kind_ident, _)) = args_parsed.output {
434 if kind_ident.to_string().eq_ignore_ascii_case("list") {
435 responses_tokens.push(quote! { ::service_kit::ApiResponse { status_code: 200, description: #summary, type_name: None } });
437 let u_name = sanitize_component_name(&type_to_string(vec_inner));
439 let u_name_lit = syn::LitStr::new(&u_name, proc_macro2::Span::call_site());
440 let u_tokens = quote! { #vec_inner };
441 response_dto_inventory_tokens.push(quote! {
442 ::service_kit::inventory::submit! {
443 ::service_kit::ApiDtoMetadata {
444 name: #u_name_lit,
445 schema_provider: || (
446 #u_name_lit.to_string(),
447 <#u_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
448 ),
449 }
450 }
451 });
452 let nested_types = collect_concrete_generic_types(vec_inner);
453 for (nested_name, nested_ty) in nested_types {
454 let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
455 let nested_ty_tokens = quote! { #nested_ty };
456 response_dto_inventory_tokens.push(quote! {
457 ::service_kit::inventory::submit! {
458 ::service_kit::ApiDtoMetadata {
459 name: #nested_name_lit,
460 schema_provider: || (
461 #nested_name_lit.to_string(),
462 <#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
463 ),
464 }
465 }
466 });
467 }
468 } else {
469 panic!("Json<Vec<T>> return type must use output(list = \"T\").");
470 }
471 } else {
472 panic!("Json<Vec<T>> return type must use output(list = \"T\").");
473 }
474 } else {
475 let type_name = type_to_string(inner_type);
477 let type_name_s = sanitize_component_name(&type_name);
478 let type_name_lit = syn::LitStr::new(&type_name_s, proc_macro2::Span::call_site());
479 responses_tokens.push(quote! {
480 ::service_kit::ApiResponse {
481 status_code: 200,
482 description: #summary,
483 type_name: Some(#type_name_lit),
484 }
485 });
486 let type_ident_tokens = quote! { #inner_type };
487 response_dto_inventory_tokens.push(quote! {
488 ::service_kit::inventory::submit! {
489 ::service_kit::ApiDtoMetadata {
490 name: #type_name_lit,
491 schema_provider: || (
492 #type_name_lit.to_string(),
493 <#type_ident_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
494 ),
495 }
496 }
497 });
498 let nested_types = collect_concrete_generic_types(inner_type);
499 for (nested_name, nested_ty) in nested_types {
500 let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
501 let nested_ty_tokens = quote! { #nested_ty };
502 response_dto_inventory_tokens.push(quote! {
503 ::service_kit::inventory::submit! {
504 ::service_kit::ApiDtoMetadata {
505 name: #nested_name_lit,
506 schema_provider: || (
507 #nested_name_lit.to_string(),
508 <#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
509 ),
510 }
511 }
512 });
513 }
514 }
515 }
516 }
517 if responses_tokens.is_empty() {
519 responses_tokens.push(quote! {
520 ::service_kit::ApiResponse { status_code: 200, description: "Success", type_name: None }
521 });
522 }
523
524 for (code_lit, desc_lit, ty_opt) in &args_parsed.responses {
526 if let Some(ty) = ty_opt {
527 let ty_str = type_to_string(ty);
528 let ty_str_s = sanitize_component_name(&ty_str);
529 let ty_str_lit = syn::LitStr::new(&ty_str_s, proc_macro2::Span::call_site());
530 responses_tokens.push(quote! {
531 ::service_kit::ApiResponse {
532 status_code: #code_lit,
533 description: #desc_lit,
534 type_name: Some(#ty_str_lit),
535 }
536 });
537 let ty_tokens = quote! { #ty };
538 response_dto_inventory_tokens.push(quote! {
539 ::service_kit::inventory::submit! {
540 ::service_kit::ApiDtoMetadata {
541 name: #ty_str_lit,
542 schema_provider: || (
543 #ty_str_lit.to_string(),
544 <#ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
545 ),
546 }
547 }
548 });
549
550 let nested_types = collect_concrete_generic_types(ty);
552 for (nested_name, nested_ty) in nested_types {
553 let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
554 let nested_ty_tokens = quote! { #nested_ty };
555 response_dto_inventory_tokens.push(quote! {
556 ::service_kit::inventory::submit! {
557 ::service_kit::ApiDtoMetadata {
558 name: #nested_name_lit,
559 schema_provider: || (
560 #nested_name_lit.to_string(),
561 <#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
562 ),
563 }
564 }
565 });
566 }
567 } else {
568 responses_tokens.push(quote! {
569 ::service_kit::ApiResponse {
570 status_code: #code_lit,
571 description: #desc_lit,
572 type_name: None,
573 }
574 });
575 }
576 }
577
578 let params_ident = format_ident!("__API_PARAMS_{}", fn_name_str.to_uppercase());
580 let responses_ident = format_ident!("__API_RESPONSES_{}", fn_name_str.to_uppercase());
581 let request_body_ident = format_ident!("__API_REQ_BODY_{}", fn_name_str.to_uppercase());
582
583 let exec_fn_ident = format_ident!("__API_EXEC_{}", fn_name_str.to_uppercase());
584
585 let output_shape_token = if let Some((ref kind_ident, ref ty_lit)) = args_parsed.output {
587 let kind = kind_ident.to_string();
588 if kind.eq_ignore_ascii_case("detail") {
589 quote! { Some(::service_kit::ApiOutputShape::Detail { type_name: #ty_lit }) }
590 } else if kind.eq_ignore_ascii_case("list") {
591 quote! { Some(::service_kit::ApiOutputShape::List { type_name: #ty_lit }) }
592 } else {
593 quote! { None }
594 }
595 } else { quote! { None } };
596
597 let static_metadata = quote! {
598 #[allow(non_upper_case_globals)]
599 const #params_ident: &[::service_kit::ApiParameter] = &[#(#params_tokens),*];
600 #[allow(non_upper_case_globals)]
601 const #responses_ident: &[::service_kit::ApiResponse] = &[#(#responses_tokens),*];
602 #[allow(non_upper_case_globals)]
603 const #request_body_ident: Option<&'static ::service_kit::ApiRequestBody> = #request_body_token;
604
605 ::service_kit::inventory::submit! {
606 ::service_kit::ApiMetadata {
607 operation_id: #fn_name_str,
608 method: #method_str,
609 path: #path_str,
610 summary: #summary,
611 description: #description,
612 parameters: #params_ident,
613 request_body: #request_body_ident,
614 responses: #responses_ident,
615 output: #output_shape_token,
616 }
617 }
618
619 fn #exec_fn_ident(__params_ref: &serde_json::Value, headers: axum::http::HeaderMap, __raw_query_str: Option<String>, state_any: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>, __body_bytes: Vec<u8>, __content_type_opt: Option<String>) -> ::service_kit::handler::DynHandlerFuture {
621 let __params_json = __params_ref.clone();
622 Box::pin(async move {
623 let params = __params_json.clone();
624 #(#arg_prepare_tokens)*
625 let __resp = #fn_ident(#(#call_args_tokens),*).await;
626 let __resp = ::axum::response::IntoResponse::into_response(__resp);
627 Ok(__resp)
628 })
629 }
630
631 ::service_kit::inventory::submit! {
633 ::service_kit::handler::ApiHandlerInventory {
634 operation_id: #fn_name_str,
635 handler: #exec_fn_ident,
636 }
637 }
638 };
639
640 let output = quote! {
642 #static_metadata
643 #(#dto_inventory_tokens)*
644 #(#response_dto_inventory_tokens)*
645 #item_fn
646 };
647
648 output.into()
649}
650
651fn type_to_string(ty: &Type) -> String {
652 quote!(#ty).to_string().replace(' ', "")
653}
654
655fn sanitize_component_name(type_str: &str) -> String {
659 if !type_str.contains('<') {
661 return strip_modules(type_str).to_string();
662 }
663
664 fn parse_type(input: &str, i: &mut usize) -> String {
666 let ident = parse_ident(input, i);
668 let mut name = strip_modules(&ident).to_string();
669 skip_ws(input, i);
670 if *i < input.len() && input.as_bytes()[*i] as char == '<' {
671 *i += 1; let mut args: Vec<String> = Vec::new();
673 loop {
674 skip_ws(input, i);
675 let arg = parse_type(input, i);
676 args.push(arg);
677 skip_ws(input, i);
678 if *i >= input.len() { break; }
679 let ch = input.as_bytes()[*i] as char;
680 if ch == ',' { *i += 1; continue; }
681 if ch == '>' { *i += 1; break; }
682 *i += 1;
684 }
685 for (idx, arg) in args.into_iter().enumerate() {
687 if idx == 0 { name.push('_'); } else { name.push('_'); }
688 name.push_str(&arg);
689 }
690 }
691 name
692 }
693
694 fn parse_ident(input: &str, i: &mut usize) -> String {
695 let start = *i;
696 while *i < input.len() {
697 let ch = input.as_bytes()[*i] as char;
698 match ch {
699 '<' | '>' | ',' => break,
700 _ => { *i += 1; }
701 }
702 }
703 input[start..*i].to_string()
704 }
705
706 fn strip_modules(ident: &str) -> String {
707 let simple = ident.rsplit("::").next().unwrap_or(ident);
709 simple.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect()
711 }
712
713 fn skip_ws(_input: &str, _i: &mut usize) {}
714
715 let mut idx = 0;
716 let result = parse_type(type_str, &mut idx);
717 if result.is_empty() || result.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '_')) {
720 panic!("Illegal sanitized component name generated: {} from {}", result, type_str);
721 }
722 result
723}
724
725fn get_inner_type<'a>(ty: &'a Type, type_name: &str) -> Option<&'a Type> {
728 if let Type::Path(type_path) = ty {
729 if let Some(segment) = type_path.path.segments.last() {
730 if segment.ident == type_name {
731 if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
732 if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
733 return Some(inner);
734 }
735 }
736 }
737 }
738 }
739 None
740}
741
742fn collect_concrete_generic_types(ty: &Type) -> Vec<(String, Type)> {
749 let mut acc: Vec<(String, Type)> = Vec::new();
750 fn is_allowed_generic_base(ident: &syn::Ident) -> bool {
751 let name = ident.to_string();
752 matches!(name.as_str(), "ApiDefinition" | "PaginatedResponse" | "ApiResponse")
753 }
754 fn visit(t: &Type, out: &mut Vec<(String, Type)>) {
755 match t {
756 Type::Path(tp) => {
757 if let Some(seg) = tp.path.segments.last() {
758 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
759 if is_allowed_generic_base(&seg.ident) {
761 let name = sanitize_component_name(&type_to_string(t));
762 out.push((name, t.clone()));
763 }
764 for arg in &args.args {
766 if let syn::GenericArgument::Type(inner_ty) = arg {
767 visit(inner_ty, out);
768 }
769 }
770 }
771 }
772 }
773 _ => {}
774 }
775 }
776 visit(ty, &mut acc);
777 let mut seen = std::collections::HashSet::new();
779 acc.into_iter()
780 .filter(|(name, _)| seen.insert(name.clone()))
781 .collect()
782}
783
784fn parse_doc_comments(attrs: &[Attribute]) -> (String, String) {
788 let doc_comments: Vec<String> = attrs
789 .iter()
790 .filter_map(|attr| {
791 if attr.path().is_ident("doc") {
792 if let syn::Meta::NameValue(nv) = &attr.meta {
793 if let syn::Expr::Lit(expr_lit) = &nv.value {
794 if let syn::Lit::Str(lit) = &expr_lit.lit {
795 return Some(lit.value().trim().to_string());
796 }
797 }
798 }
799 }
800 None
801 })
802 .collect();
803
804 let description = doc_comments.join("\n");
805 let summary = description.lines().next().unwrap_or("").to_string();
806 (summary, description)
807}
808
809#[derive(Debug, Default)]
812struct ApiDtoArgs {
813 }
815
816impl syn::parse::Parse for ApiDtoArgs {
817 fn parse(_input: ParseStream) -> Result<Self> {
818 Ok(ApiDtoArgs::default())
819 }
820}
821
822#[proc_macro_attribute]
823pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
824 let _args = parse_macro_input!(attr as ApiDtoArgs);
825 let mut input = parse_macro_input!(item as syn::DeriveInput);
826
827 let type_name = input.ident.clone();
828 let type_name_str = type_name.to_string();
829
830 let attributes_to_add = quote! {
832 #[derive(
833 Debug,
834 Clone,
835 serde::Serialize,
836 serde::Deserialize,
837 ::service_kit::utoipa::ToSchema
838 )]
839 #[serde(deny_unknown_fields)]
840 };
841
842 let mut parsed_attrs: Vec<syn::Attribute> =
843 syn::parse::Parser::parse(syn::Attribute::parse_outer, attributes_to_add.into())
844 .expect("Failed to parse attributes");
845 let mut new_attrs: Vec<syn::Attribute> = Vec::with_capacity(parsed_attrs.len() + input.attrs.len());
847 new_attrs.append(&mut parsed_attrs);
848 new_attrs.append(&mut input.attrs);
849 input.attrs = new_attrs;
850
851 if let syn::Data::Struct(ref mut data_struct) = input.data {
852 if let syn::Fields::Named(ref mut fields) = data_struct.fields {
853 for field in fields.named.iter_mut() {
854 if let Type::Path(type_path) = &field.ty {
855 if is_recursive_type(&type_path.path, &input.ident.to_string()) {
856 field.attrs.push(syn::parse_quote! {
857 #[schema(value_type = Object)]
858 });
859 }
860 }
861 }
862 }
863 }
864
865 let mut nested_regs: Vec<proc_macro2::TokenStream> = Vec::new();
867 if let syn::Data::Struct(ref data_struct) = input.data {
868 if let syn::Fields::Named(ref fields_named) = data_struct.fields {
869 for field in &fields_named.named {
870 let nested = collect_concrete_generic_types(&field.ty);
871 for (name, ty) in nested {
872 let name_lit = syn::LitStr::new(&name, proc_macro2::Span::call_site());
873 let ty_tokens = quote! { #ty };
874 nested_regs.push(quote! {
875 ::service_kit::inventory::submit! {
876 ::service_kit::ApiDtoMetadata {
877 name: #name_lit,
878 schema_provider: || (
879 #name_lit.to_string(),
880 <#ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
881 ),
882 }
883 }
884 });
885 }
886 }
887 }
888 }
889
890 let registration = if input.generics.params.is_empty() {
892 quote! {
893 ::service_kit::inventory::submit! {
894 ::service_kit::ApiDtoMetadata {
895 name: #type_name_str,
896 schema_provider: || {
897 (
898 #type_name_str.to_string(),
899 <#type_name as ::service_kit::utoipa::PartialSchema>::schema(),
900 )
901 },
902 }
903 }
904 }
905 } else {
906 quote! {}
907 };
908
909 let output = quote! {
910 #input
911 #registration
912 #(#nested_regs)*
913 };
914
915 output.into()
916}
917
918fn is_recursive_type(path: &syn::Path, self_name: &str) -> bool {
919 if let Some(segment) = path.segments.last() {
920 let type_name = segment.ident.to_string();
921 if type_name == "Box" || type_name == "Option" {
922 if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
923 if let Some(syn::GenericArgument::Type(Type::Path(inner_type_path))) = args.args.first()
924 {
925 if type_name == "Option" {
926 if let Some(inner_segment) = inner_type_path.path.segments.last() {
927 if inner_segment.ident == "Box" {
928 return is_recursive_boxed_type(inner_segment, self_name);
929 }
930 }
931 } else {
932 return is_recursive_boxed_type(segment, self_name);
933 }
934 }
935 }
936 }
937 }
938 false
939}
940
941fn is_recursive_boxed_type(segment: &syn::PathSegment, self_name: &str) -> bool {
942 if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
943 if let Some(syn::GenericArgument::Type(Type::Path(inner_type))) = args.args.first() {
944 if let Some(inner_segment) = inner_type.path.segments.last() {
945 return inner_segment.ident == self_name;
946 }
947 }
948 }
949 false
950}