extern crate proc_macro;
use proc_macro::TokenStream;
use quote::{quote, format_ident};
use syn::{
parse::Parse, parse::ParseStream, parse_macro_input, Attribute, FnArg, Ident, ItemFn, LitStr,
Pat, Result, ReturnType, Token, Type
};
struct ApiMacroArgs {
method: Ident,
path: LitStr,
responses: Vec<(syn::LitInt, LitStr, Option<Type>)>,
output: Option<(Ident, LitStr)>, }
impl Parse for ApiMacroArgs {
fn parse(input: ParseStream) -> Result<Self> {
let method: Ident = input.parse()?;
input.parse::<Token![,]>()?;
let path: LitStr = input.parse()?;
let mut responses: Vec<(syn::LitInt, LitStr, Option<Type>)> = Vec::new();
let mut output: Option<(Ident, LitStr)> = None;
while input.parse::<Option<Token![,]>>()?.is_some() {
if input.peek(Ident) {
let kw: Ident = input.parse()?;
let kw_str = kw.to_string();
if kw_str == "responses" {
let content;
syn::parenthesized!(content in input);
while !content.is_empty() {
let group;
syn::parenthesized!(group in content);
let code: syn::LitInt = group.parse()?;
group.parse::<Token![,]>()?;
let desc: LitStr = group.parse()?;
let ty: Option<Type> = if group.parse::<Option<Token![,]>>()?.is_some() {
Some(group.parse()?)
} else { None };
responses.push((code, desc, ty));
let _ = content.parse::<Option<Token![,]>>()?;
}
} else if kw_str == "output" {
let content;
syn::parenthesized!(content in input);
let kind: Ident = content.parse()?; content.parse::<Token![=]>()?;
let ty_lit: LitStr = content.parse()?; output = Some((kind, ty_lit));
} else {
break;
}
} else {
break;
}
}
Ok(ApiMacroArgs { method, path, responses, output })
}
}
#[proc_macro_attribute]
pub fn api(args: TokenStream, input: TokenStream) -> TokenStream {
let item_fn = parse_macro_input!(input as ItemFn);
let args_parsed = parse_macro_input!(args as ApiMacroArgs);
let fn_ident = &item_fn.sig.ident;
let fn_name_str = fn_ident.to_string();
let method_str = args_parsed.method.to_string();
let path_str = args_parsed.path.value();
let (summary, description) = parse_doc_comments(&item_fn.attrs);
let mut params_tokens = Vec::new();
let mut request_body_token = quote! { None };
let mut _needs_header_map: bool = false;
let mut _needs_state: bool = false;
let mut dto_inventory_tokens: Vec<proc_macro2::TokenStream> = Vec::new();
let mut arg_prepare_tokens = Vec::new();
let mut call_args_tokens = Vec::new();
let mut _state_type_tokens: Option<proc_macro2::TokenStream> = None;
for arg in &item_fn.sig.inputs {
if let FnArg::Typed(pat_type) = arg {
if let Some(inner_type) = get_inner_type(&pat_type.ty, "Path") {
if let Pat::TupleStruct(pat_tuple) = &*pat_type.pat {
if let Some(Pat::Ident(inner_pat)) = pat_tuple.elems.first() {
let param_name = inner_pat.ident.to_string();
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
params_tokens.push(quote! {
::service_kit::ApiParameter {
name: #param_name,
param_in: ::service_kit::ParamIn::Path,
description: "", required: true,
type_name: #type_name_s,
}
});
let var_ident = &inner_pat.ident;
let inner_ty_tokens = quote! { #inner_type };
arg_prepare_tokens.push(quote! {
let #var_ident: #inner_ty_tokens = match params.get(#param_name).and_then(|v| v.as_str()) {
Some(s) => match s.parse::<#inner_ty_tokens>() {
Ok(v) => v,
Err(_) => return Err(::service_kit::error::Error::SpecError(format!("invalid path param `{}`", #param_name))),
},
None => return Err(::service_kit::error::Error::SpecError(format!("missing path param `{}`", #param_name))),
};
let #var_ident = axum::extract::Path::<#inner_ty_tokens>(#var_ident);
});
call_args_tokens.push(quote! { #var_ident });
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_s,
schema_provider: || (
#type_name_s.to_string(),
<#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
} else if let Pat::Ident(pat_ident) = &*pat_type.pat {
let param_name = pat_ident.ident.to_string();
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
params_tokens.push(quote! {
::service_kit::ApiParameter {
name: #param_name,
param_in: ::service_kit::ParamIn::Path,
description: "",
required: true,
type_name: #type_name_s,
}
});
let var_ident = &pat_ident.ident;
let inner_ty_tokens = quote! { #inner_type };
arg_prepare_tokens.push(quote! {
let #var_ident: #inner_ty_tokens = match params.get(#param_name).and_then(|v| v.as_str()) {
Some(s) => match s.parse::<#inner_ty_tokens>() {
Ok(v) => v,
Err(_) => return Err(::service_kit::error::Error::SpecError(format!("invalid path param `{}`", #param_name))),
},
None => return Err(::service_kit::error::Error::SpecError(format!("missing path param `{}`", #param_name))),
};
let #var_ident = axum::extract::Path::<#inner_ty_tokens>(#var_ident);
});
call_args_tokens.push(quote! { #var_ident });
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_s,
schema_provider: || (
#type_name_s.to_string(),
<#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
} else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Query") {
let param_name_opt = if let Pat::TupleStruct(pat_tuple) = &*pat_type.pat {
pat_tuple
.elems
.first()
.and_then(|p| match p { Pat::Ident(pi) => Some(pi.ident.to_string()), _ => None })
} else if let Pat::Ident(pat_ident) = &*pat_type.pat {
Some(pat_ident.ident.to_string())
} else { None };
if let Some(param_name) = param_name_opt {
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
if type_name != "AuthQuery" && !type_name.ends_with("::AuthQuery") {
params_tokens.push(quote! {
::service_kit::ApiParameter {
name: #param_name,
param_in: ::service_kit::ParamIn::Query,
description: "", required: true, type_name: #type_name_s,
}
});
}
let var_ident = format_ident!("{}", param_name);
let inner_ty_tokens = quote! { #inner_type };
arg_prepare_tokens.push(quote! {
let #var_ident: #inner_ty_tokens = match __raw_query_str.as_deref() {
Some(s) if !s.is_empty() => match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>(s) {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeUrlencoded(e)),
},
_ => match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>("") {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeUrlencoded(e)),
}
};
let #var_ident = axum::extract::Query::<#inner_ty_tokens>(#var_ident);
});
call_args_tokens.push(quote! { #var_ident });
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
if type_name != "AuthQuery" && !type_name.ends_with("::AuthQuery") {
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_s,
schema_provider: || (
#type_name_s.to_string(),
<#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
}
} 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" {
_needs_header_map = true;
if let Pat::Ident(pat_ident) = &*pat_type.pat {
let var_ident = &pat_ident.ident;
arg_prepare_tokens.push(quote! {
let #var_ident: axum::http::HeaderMap = headers.clone();
});
call_args_tokens.push(quote! { #var_ident });
}
} else if let Some(inner_type) = get_inner_type(&pat_type.ty, "State") {
_needs_state = true;
_state_type_tokens = Some(quote! { #inner_type });
let state_var_ident = format_ident!("__state_arg_{}", call_args_tokens.len());
arg_prepare_tokens.push(quote! {
let #state_var_ident: axum::extract::State<#inner_type> = {
let any_arc = state_any.ok_or_else(|| ::service_kit::error::Error::SpecError("missing router state".to_string()))?;
let typed = (&*any_arc).downcast_ref::<#inner_type>().ok_or_else(|| ::service_kit::error::Error::SpecError("invalid router state type".to_string()))?;
axum::extract::State::<#inner_type>(typed.clone())
};
});
call_args_tokens.push(quote! { #state_var_ident });
} else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Json") {
if get_inner_type(inner_type, "Vec").is_some() {
panic!("Json<Vec<T>> in request body is not allowed. Wrap it in a DTO with a Vec<T> field.");
}
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
request_body_token = quote! {
Some(&::service_kit::ApiRequestBody {
description: "", required: true,
type_name: #type_name_s,
})
};
let inner_ty_tokens = quote! { #inner_type };
let json_ident = syn::Ident::new("__json_body", proc_macro2::Span::call_site());
arg_prepare_tokens.push(quote! {
let __body_json_source: serde_json::Value = match __content_type_opt.as_deref() {
Some(ct) if ct.contains("application/json") => {
if __body_bytes.is_empty() {
serde_json::Value::Null
} else {
match serde_json::from_slice::<serde_json::Value>(&__body_bytes) {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
}
}
},
_ => serde_json::Value::Null,
};
let #json_ident: #inner_ty_tokens = if !__body_json_source.is_null() {
match serde_json::from_value(__body_json_source) {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
}
} else {
match serde_json::from_value(params.clone()) {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
}
};
let #json_ident = axum::Json::<#inner_ty_tokens>(#json_ident);
});
call_args_tokens.push(quote! { #json_ident });
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_s,
schema_provider: || (
#type_name_s.to_string(),
<#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
let nested_types = collect_concrete_generic_types(inner_type);
for (nested_name, nested_ty) in nested_types {
let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
let nested_ty_tokens = quote! { #nested_ty };
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #nested_name_lit,
schema_provider: || (
#nested_name_lit.to_string(),
<#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
}
else if let Some(inner_type) = get_inner_type(&pat_type.ty, "Form") {
if get_inner_type(inner_type, "Vec").is_some() {
panic!("Form<Vec<T>> in request body is not allowed. Wrap it in a DTO with a Vec<T> field.");
}
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
request_body_token = quote! {
Some(&::service_kit::ApiRequestBody {
description: "", required: true,
type_name: #type_name_s,
})
};
let inner_ty_tokens = quote! { #inner_type };
let form_ident = syn::Ident::new("__form_body", proc_macro2::Span::call_site());
arg_prepare_tokens.push(quote! {
let #form_ident: #inner_ty_tokens = match __content_type_opt.as_deref() {
Some(ct) if ct.contains("application/x-www-form-urlencoded") => {
let raw = String::from_utf8(__body_bytes.clone()).unwrap_or_default();
match ::service_kit::serde_urlencoded::from_str::<#inner_ty_tokens>(&raw) {
Ok(v) => v,
Err(_e) => {
match serde_json::from_slice::<#inner_ty_tokens>(&__body_bytes) {
Ok(v) => v,
Err(e2) => return Err(::service_kit::error::Error::SerdeJson(e2)),
}
}
}
},
_ => {
match serde_json::from_value(params.clone()) {
Ok(v) => v,
Err(e) => return Err(::service_kit::error::Error::SerdeJson(e)),
}
}
};
let #form_ident = axum::Form::<#inner_ty_tokens>(#form_ident);
});
call_args_tokens.push(quote! { #form_ident });
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_s,
schema_provider: || (
#type_name_s.to_string(),
<#inner_type as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
let nested_types = collect_concrete_generic_types(inner_type);
for (nested_name, nested_ty) in nested_types {
let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
let nested_ty_tokens = quote! { #nested_ty };
dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #nested_name_lit,
schema_provider: || (
#nested_name_lit.to_string(),
<#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
}
}
}
let mut responses_tokens = Vec::new();
let mut response_dto_inventory_tokens: Vec<proc_macro2::TokenStream> = Vec::new();
if let ReturnType::Type(_, ty) = &item_fn.sig.output {
if let Some(inner_type) = get_inner_type(ty, "Json") {
if let Some(vec_inner) = get_inner_type(inner_type, "Vec") {
if let Some((ref kind_ident, _)) = args_parsed.output {
if kind_ident.to_string().eq_ignore_ascii_case("list") {
responses_tokens.push(quote! { ::service_kit::ApiResponse { status_code: 200, description: #summary, type_name: None } });
let u_name = sanitize_component_name(&type_to_string(vec_inner));
let u_name_lit = syn::LitStr::new(&u_name, proc_macro2::Span::call_site());
let u_tokens = quote! { #vec_inner };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #u_name_lit,
schema_provider: || (
#u_name_lit.to_string(),
<#u_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
let nested_types = collect_concrete_generic_types(vec_inner);
for (nested_name, nested_ty) in nested_types {
let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
let nested_ty_tokens = quote! { #nested_ty };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #nested_name_lit,
schema_provider: || (
#nested_name_lit.to_string(),
<#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
} else {
panic!("Json<Vec<T>> return type must use output(list = \"T\").");
}
} else {
panic!("Json<Vec<T>> return type must use output(list = \"T\").");
}
} else {
let type_name = type_to_string(inner_type);
let type_name_s = sanitize_component_name(&type_name);
let type_name_lit = syn::LitStr::new(&type_name_s, proc_macro2::Span::call_site());
responses_tokens.push(quote! {
::service_kit::ApiResponse {
status_code: 200,
description: #summary,
type_name: Some(#type_name_lit),
}
});
let type_ident_tokens = quote! { #inner_type };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_lit,
schema_provider: || (
#type_name_lit.to_string(),
<#type_ident_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
let nested_types = collect_concrete_generic_types(inner_type);
for (nested_name, nested_ty) in nested_types {
let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
let nested_ty_tokens = quote! { #nested_ty };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #nested_name_lit,
schema_provider: || (
#nested_name_lit.to_string(),
<#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
}
}
}
if responses_tokens.is_empty() {
responses_tokens.push(quote! {
::service_kit::ApiResponse { status_code: 200, description: "Success", type_name: None }
});
}
for (code_lit, desc_lit, ty_opt) in &args_parsed.responses {
if let Some(ty) = ty_opt {
let ty_str = type_to_string(ty);
let ty_str_s = sanitize_component_name(&ty_str);
let ty_str_lit = syn::LitStr::new(&ty_str_s, proc_macro2::Span::call_site());
responses_tokens.push(quote! {
::service_kit::ApiResponse {
status_code: #code_lit,
description: #desc_lit,
type_name: Some(#ty_str_lit),
}
});
let ty_tokens = quote! { #ty };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #ty_str_lit,
schema_provider: || (
#ty_str_lit.to_string(),
<#ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
let nested_types = collect_concrete_generic_types(ty);
for (nested_name, nested_ty) in nested_types {
let nested_name_lit = syn::LitStr::new(&nested_name, proc_macro2::Span::call_site());
let nested_ty_tokens = quote! { #nested_ty };
response_dto_inventory_tokens.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #nested_name_lit,
schema_provider: || (
#nested_name_lit.to_string(),
<#nested_ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
} else {
responses_tokens.push(quote! {
::service_kit::ApiResponse {
status_code: #code_lit,
description: #desc_lit,
type_name: None,
}
});
}
}
let params_ident = format_ident!("__API_PARAMS_{}", fn_name_str.to_uppercase());
let responses_ident = format_ident!("__API_RESPONSES_{}", fn_name_str.to_uppercase());
let request_body_ident = format_ident!("__API_REQ_BODY_{}", fn_name_str.to_uppercase());
let exec_fn_ident = format_ident!("__API_EXEC_{}", fn_name_str.to_uppercase());
let output_shape_token = if let Some((ref kind_ident, ref ty_lit)) = args_parsed.output {
let kind = kind_ident.to_string();
if kind.eq_ignore_ascii_case("detail") {
quote! { Some(::service_kit::ApiOutputShape::Detail { type_name: #ty_lit }) }
} else if kind.eq_ignore_ascii_case("list") {
quote! { Some(::service_kit::ApiOutputShape::List { type_name: #ty_lit }) }
} else {
quote! { None }
}
} else { quote! { None } };
let static_metadata = quote! {
#[allow(non_upper_case_globals)]
const #params_ident: &[::service_kit::ApiParameter] = &[#(#params_tokens),*];
#[allow(non_upper_case_globals)]
const #responses_ident: &[::service_kit::ApiResponse] = &[#(#responses_tokens),*];
#[allow(non_upper_case_globals)]
const #request_body_ident: Option<&'static ::service_kit::ApiRequestBody> = #request_body_token;
::service_kit::inventory::submit! {
::service_kit::ApiMetadata {
operation_id: #fn_name_str,
method: #method_str,
path: #path_str,
summary: #summary,
description: #description,
parameters: #params_ident,
request_body: #request_body_ident,
responses: #responses_ident,
output: #output_shape_token,
}
}
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 {
let __params_json = __params_ref.clone();
Box::pin(async move {
let params = __params_json.clone();
#(#arg_prepare_tokens)*
let __resp = #fn_ident(#(#call_args_tokens),*).await;
let __resp = ::axum::response::IntoResponse::into_response(__resp);
Ok(__resp)
})
}
::service_kit::inventory::submit! {
::service_kit::handler::ApiHandlerInventory {
operation_id: #fn_name_str,
handler: #exec_fn_ident,
}
}
};
let output = quote! {
#static_metadata
#(#dto_inventory_tokens)*
#(#response_dto_inventory_tokens)*
#item_fn
};
output.into()
}
fn type_to_string(ty: &Type) -> String {
quote!(#ty).to_string().replace(' ', "")
}
fn sanitize_component_name(type_str: &str) -> String {
if !type_str.contains('<') {
return strip_modules(type_str).to_string();
}
fn parse_type(input: &str, i: &mut usize) -> String {
let ident = parse_ident(input, i);
let mut name = strip_modules(&ident).to_string();
skip_ws(input, i);
if *i < input.len() && input.as_bytes()[*i] as char == '<' {
*i += 1; let mut args: Vec<String> = Vec::new();
loop {
skip_ws(input, i);
let arg = parse_type(input, i);
args.push(arg);
skip_ws(input, i);
if *i >= input.len() { break; }
let ch = input.as_bytes()[*i] as char;
if ch == ',' { *i += 1; continue; }
if ch == '>' { *i += 1; break; }
*i += 1;
}
for (idx, arg) in args.into_iter().enumerate() {
if idx == 0 { name.push('_'); } else { name.push('_'); }
name.push_str(&arg);
}
}
name
}
fn parse_ident(input: &str, i: &mut usize) -> String {
let start = *i;
while *i < input.len() {
let ch = input.as_bytes()[*i] as char;
match ch {
'<' | '>' | ',' => break,
_ => { *i += 1; }
}
}
input[start..*i].to_string()
}
fn strip_modules(ident: &str) -> String {
let simple = ident.rsplit("::").next().unwrap_or(ident);
simple.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect()
}
fn skip_ws(_input: &str, _i: &mut usize) {}
let mut idx = 0;
let result = parse_type(type_str, &mut idx);
if result.is_empty() || result.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '_')) {
panic!("Illegal sanitized component name generated: {} from {}", result, type_str);
}
result
}
fn get_inner_type<'a>(ty: &'a Type, type_name: &str) -> Option<&'a Type> {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
if segment.ident == type_name {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
return Some(inner);
}
}
}
}
}
None
}
fn collect_concrete_generic_types(ty: &Type) -> Vec<(String, Type)> {
let mut acc: Vec<(String, Type)> = Vec::new();
fn is_allowed_generic_base(ident: &syn::Ident) -> bool {
let name = ident.to_string();
matches!(name.as_str(), "ApiDefinition" | "PaginatedResponse" | "ApiResponse")
}
fn visit(t: &Type, out: &mut Vec<(String, Type)>) {
match t {
Type::Path(tp) => {
if let Some(seg) = tp.path.segments.last() {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if is_allowed_generic_base(&seg.ident) {
let name = sanitize_component_name(&type_to_string(t));
out.push((name, t.clone()));
}
for arg in &args.args {
if let syn::GenericArgument::Type(inner_ty) = arg {
visit(inner_ty, out);
}
}
}
}
}
_ => {}
}
}
visit(ty, &mut acc);
let mut seen = std::collections::HashSet::new();
acc.into_iter()
.filter(|(name, _)| seen.insert(name.clone()))
.collect()
}
fn parse_doc_comments(attrs: &[Attribute]) -> (String, String) {
let doc_comments: Vec<String> = attrs
.iter()
.filter_map(|attr| {
if attr.path().is_ident("doc") {
if let syn::Meta::NameValue(nv) = &attr.meta {
if let syn::Expr::Lit(expr_lit) = &nv.value {
if let syn::Lit::Str(lit) = &expr_lit.lit {
return Some(lit.value().trim().to_string());
}
}
}
}
None
})
.collect();
let description = doc_comments.join("\n");
let summary = description.lines().next().unwrap_or("").to_string();
(summary, description)
}
#[derive(Debug, Default)]
struct ApiDtoArgs {
}
impl syn::parse::Parse for ApiDtoArgs {
fn parse(_input: ParseStream) -> Result<Self> {
Ok(ApiDtoArgs::default())
}
}
#[proc_macro_attribute]
pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
let _args = parse_macro_input!(attr as ApiDtoArgs);
let mut input = parse_macro_input!(item as syn::DeriveInput);
let type_name = input.ident.clone();
let type_name_str = type_name.to_string();
let attributes_to_add = quote! {
#[derive(
Debug,
Clone,
serde::Serialize,
serde::Deserialize,
::service_kit::utoipa::ToSchema
)]
#[serde(deny_unknown_fields)]
};
let mut parsed_attrs: Vec<syn::Attribute> =
syn::parse::Parser::parse(syn::Attribute::parse_outer, attributes_to_add.into())
.expect("Failed to parse attributes");
let mut new_attrs: Vec<syn::Attribute> = Vec::with_capacity(parsed_attrs.len() + input.attrs.len());
new_attrs.append(&mut parsed_attrs);
new_attrs.append(&mut input.attrs);
input.attrs = new_attrs;
if let syn::Data::Struct(ref mut data_struct) = input.data {
if let syn::Fields::Named(ref mut fields) = data_struct.fields {
for field in fields.named.iter_mut() {
if let Type::Path(type_path) = &field.ty {
if is_recursive_type(&type_path.path, &input.ident.to_string()) {
field.attrs.push(syn::parse_quote! {
#[schema(value_type = Object)]
});
}
}
}
}
}
let mut nested_regs: Vec<proc_macro2::TokenStream> = Vec::new();
if let syn::Data::Struct(ref data_struct) = input.data {
if let syn::Fields::Named(ref fields_named) = data_struct.fields {
for field in &fields_named.named {
let nested = collect_concrete_generic_types(&field.ty);
for (name, ty) in nested {
let name_lit = syn::LitStr::new(&name, proc_macro2::Span::call_site());
let ty_tokens = quote! { #ty };
nested_regs.push(quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #name_lit,
schema_provider: || (
#name_lit.to_string(),
<#ty_tokens as ::service_kit::utoipa::PartialSchema>::schema(),
),
}
}
});
}
}
}
}
let registration = if input.generics.params.is_empty() {
quote! {
::service_kit::inventory::submit! {
::service_kit::ApiDtoMetadata {
name: #type_name_str,
schema_provider: || {
(
#type_name_str.to_string(),
<#type_name as ::service_kit::utoipa::PartialSchema>::schema(),
)
},
}
}
}
} else {
quote! {}
};
let output = quote! {
#input
#registration
#(#nested_regs)*
};
output.into()
}
fn is_recursive_type(path: &syn::Path, self_name: &str) -> bool {
if let Some(segment) = path.segments.last() {
let type_name = segment.ident.to_string();
if type_name == "Box" || type_name == "Option" {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(Type::Path(inner_type_path))) = args.args.first()
{
if type_name == "Option" {
if let Some(inner_segment) = inner_type_path.path.segments.last() {
if inner_segment.ident == "Box" {
return is_recursive_boxed_type(inner_segment, self_name);
}
}
} else {
return is_recursive_boxed_type(segment, self_name);
}
}
}
}
}
false
}
fn is_recursive_boxed_type(segment: &syn::PathSegment, self_name: &str) -> bool {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(Type::Path(inner_type))) = args.args.first() {
if let Some(inner_segment) = inner_type.path.segments.last() {
return inner_segment.ident == self_name;
}
}
}
false
}