use proc_macro2::TokenStream;
use quote::{quote, ToTokens};
use syn::{
parse::Parser, parse2, punctuated::Punctuated, ItemFn, LitStr, Meta, Result, Token, Type,
};
use crate::sig;
pub fn expand(method: &str, args: TokenStream, item: TokenStream) -> Result<TokenStream> {
let parsed_args = parse_method_args(args)?;
let item_fn: ItemFn = parse2(item)?;
let method_ident = syn::Ident::new(method, proc_macro2::Span::call_site());
let path_lit = parsed_args.path;
let extra = parsed_args.extra;
let utoipa_attr = build_utoipa_attr(&method_ident, &path_lit, &extra, &item_fn)?;
Ok(quote! {
#utoipa_attr
#item_fn
})
}
pub fn expand_operation(args: TokenStream, item: TokenStream) -> Result<TokenStream> {
let parsed = parse_operation_args(args)?;
let item_fn: ItemFn = parse2(item)?;
let utoipa_attr = build_utoipa_attr(&parsed.method, &parsed.path, &parsed.extra, &item_fn)?;
Ok(quote! {
#utoipa_attr
#item_fn
})
}
struct MethodArgs {
path: LitStr,
extra: Vec<Meta>,
}
struct OperationArgs {
method: syn::Ident,
path: LitStr,
extra: Vec<Meta>,
}
fn parse_method_args(args: TokenStream) -> Result<MethodArgs> {
let parser = Punctuated::<MethodArg, Token![,]>::parse_terminated;
let parsed = parser.parse2(args)?;
let mut iter = parsed.into_iter();
let first = iter
.next()
.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "expected a path string"))?;
let path = match first {
MethodArg::Path(lit) => lit,
MethodArg::Meta(meta) => {
return Err(syn::Error::new_spanned(
meta,
"first argument must be a path literal like \"/api/v1/foo\"",
))
}
};
let extra = iter
.map(|arg| match arg {
MethodArg::Meta(meta) => Ok(*meta),
MethodArg::Path(lit) => Err(syn::Error::new_spanned(
lit,
"only the first argument may be a path literal",
)),
})
.collect::<Result<Vec<_>>>()?;
Ok(MethodArgs { path, extra })
}
fn parse_operation_args(args: TokenStream) -> Result<OperationArgs> {
let parser = Punctuated::<OperationArg, Token![,]>::parse_terminated;
let parsed = parser.parse2(args)?;
let mut iter = parsed.into_iter();
let method = match iter.next() {
Some(OperationArg::Method(ident)) => ident,
Some(other) => {
return Err(syn::Error::new_spanned(
other.token_stream_for_error(),
"first argument to operation must be an HTTP method identifier",
))
}
None => {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"operation requires `(method, \"/path\", ...)`",
))
}
};
let path = match iter.next() {
Some(OperationArg::Path(lit)) => lit,
Some(other) => {
return Err(syn::Error::new_spanned(
other.token_stream_for_error(),
"second argument to operation must be a path literal",
))
}
None => {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"operation requires a path literal after the method",
))
}
};
let extra = iter
.map(|arg| match arg {
OperationArg::Meta(meta) => Ok(*meta),
OperationArg::Method(ident) => Err(syn::Error::new_spanned(
ident,
"only the first argument may be the HTTP method",
)),
OperationArg::Path(lit) => Err(syn::Error::new_spanned(
lit,
"only the second argument may be a path literal",
)),
})
.collect::<Result<Vec<_>>>()?;
Ok(OperationArgs {
method,
path,
extra,
})
}
enum MethodArg {
Path(LitStr),
Meta(Box<Meta>),
}
impl syn::parse::Parse for MethodArg {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
if input.peek(LitStr) {
let lit: LitStr = input.parse()?;
Ok(MethodArg::Path(lit))
} else {
let meta: Meta = input.parse()?;
Ok(MethodArg::Meta(Box::new(meta)))
}
}
}
enum OperationArg {
Method(syn::Ident),
Path(LitStr),
Meta(Box<Meta>),
}
impl OperationArg {
fn token_stream_for_error(&self) -> TokenStream {
match self {
OperationArg::Method(i) => i.to_token_stream(),
OperationArg::Path(l) => l.to_token_stream(),
OperationArg::Meta(m) => m.to_token_stream(),
}
}
}
impl syn::parse::Parse for OperationArg {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
if input.peek(LitStr) {
Ok(OperationArg::Path(input.parse()?))
} else if input.peek(syn::Ident)
&& !input.peek2(Token![=])
&& !input.peek2(syn::token::Paren)
{
Ok(OperationArg::Method(input.parse()?))
} else {
Ok(OperationArg::Meta(Box::new(input.parse()?)))
}
}
}
fn build_utoipa_attr(
method: &syn::Ident,
path: &LitStr,
extra: &[Meta],
item_fn: &ItemFn,
) -> Result<TokenStream> {
let has_operation_id = extra.iter().any(|m| m.path().is_ident("operation_id"));
let fn_name = item_fn.sig.ident.to_string();
let operation_id_tt = if has_operation_id {
quote! {}
} else {
let id = LitStr::new(&fn_name, proc_macro2::Span::call_site());
quote! { , operation_id = #id }
};
let (explicit_headers, after_headers) = extract_headers_arg(extra)?;
let (explicit_tags, forwarded_extra) = extract_tags_arg(&after_headers)?;
let user_keys = sig::collect_user_keys(&forwarded_extra);
let mut inferred = sig::infer(item_fn);
merge_explicit_headers(&mut inferred.header_marker_types, explicit_headers);
let path_param_names = sig::parse_path_names(&path.value());
let sig::InferredTokens {
pre_items,
attr_additions,
} = inferred.into_tokens(&item_fn.sig.ident, &path_param_names, &user_keys);
let tags_tt = if !explicit_tags.is_empty() && !user_keys.contains(&"tag") {
quote! { , tags = [#(#explicit_tags),*] }
} else {
quote! {}
};
let extra_tt = if forwarded_extra.is_empty() {
quote! {}
} else {
let metas = forwarded_extra.iter();
quote! { , #(#metas),* }
};
Ok(quote! {
#pre_items
#[::utoipa::path(
#method,
path = #path
#operation_id_tt
#attr_additions
#tags_tt
#extra_tt
)]
})
}
fn extract_headers_arg(extra: &[Meta]) -> Result<(Vec<Type>, Vec<Meta>)> {
let mut header_types = Vec::new();
let mut forwarded = Vec::with_capacity(extra.len());
for meta in extra {
if meta.path().is_ident("headers") {
let Meta::List(list) = meta else {
return Err(syn::Error::new_spanned(
meta,
"expected `headers(Type1, Type2, ...)`",
));
};
let parser = Punctuated::<Type, Token![,]>::parse_terminated;
let parsed = parser.parse2(list.tokens.clone())?;
header_types.extend(parsed.into_iter());
} else {
forwarded.push(meta.clone());
}
}
Ok((header_types, forwarded))
}
fn extract_tags_arg(extra: &[Meta]) -> Result<(Vec<LitStr>, Vec<Meta>)> {
let mut tag_lits = Vec::new();
let mut forwarded = Vec::with_capacity(extra.len());
for meta in extra {
if meta.path().is_ident("tags") {
let Meta::List(list) = meta else {
return Err(syn::Error::new_spanned(
meta,
"expected `tags(\"Tag1\", \"Tag2\", ...)`",
));
};
let parser = Punctuated::<LitStr, Token![,]>::parse_terminated;
let parsed = parser.parse2(list.tokens.clone())?;
tag_lits.extend(parsed.into_iter());
} else {
forwarded.push(meta.clone());
}
}
Ok((tag_lits, forwarded))
}
fn merge_explicit_headers(inferred: &mut Vec<Type>, explicit: Vec<Type>) {
let mut seen: Vec<String> = inferred
.iter()
.map(|t| t.to_token_stream().to_string())
.collect();
for ty in explicit {
let key = ty.to_token_stream().to_string();
if !seen.contains(&key) {
seen.push(key);
inferred.push(ty);
}
}
}