Skip to main content

dioxus_openapi_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::{format_ident, quote};
3use syn::{
4    parse::{Parse, ParseStream},
5    punctuated::Punctuated,
6    Expr, FnArg, GenericArgument, ItemFn, LitBool, LitStr, Meta, Pat, PathArguments, ReturnType,
7    Token, Type,
8};
9
10/// `#[api_get("/path", tag = "...", ...)]`
11#[proc_macro_attribute]
12pub fn api_get(attr: TokenStream, item: TokenStream) -> TokenStream {
13    expand("get", attr, item)
14}
15
16/// `#[api_post("/path", tag = "...", ...)]`
17#[proc_macro_attribute]
18pub fn api_post(attr: TokenStream, item: TokenStream) -> TokenStream {
19    expand("post", attr, item)
20}
21
22fn expand(method: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
23    let args = syn::parse_macro_input!(attr as ApiArgs);
24    let mut func = syn::parse_macro_input!(item as ItemFn);
25
26    if args.openapi == Some(false) {
27        let dioxus_attr = dioxus_route_attr(method, &args.path);
28        return quote! {
29            #dioxus_attr
30            #func
31        }
32        .into();
33    }
34
35    let tag = match &args.tag {
36        Some(t) => t.clone(),
37        None => {
38            return syn::Error::new_spanned(
39                &func.sig.ident,
40                "api_get/api_post require `tag = \"...\"` (or `openapi = false`)",
41            )
42            .to_compile_error()
43            .into();
44        }
45    };
46
47    let path_lit = &args.path;
48    let path_value = path_lit.value();
49    let fn_ident = func.sig.ident.clone();
50    let openapi_fn = format_ident!("__openapi_{}", fn_ident);
51    let body_struct = format_ident!("__openapi_body_{}", fn_ident);
52    let operation_id = fn_ident.to_string();
53
54    let path_params = parse_path_params(&path_value);
55    let arg_map = fn_arg_map(&func);
56
57    // Path param entries for utoipa.
58    let mut param_tokens = Vec::new();
59    for name in &path_params {
60        let Some((_, (ty, _))) = arg_map.iter().find(|(n, _)| n == name) else {
61            return syn::Error::new_spanned(
62                &func.sig.ident,
63                format!("path parameter `{{{name}}}` has no matching function argument"),
64            )
65            .to_compile_error()
66            .into();
67        };
68        let name_lit = LitStr::new(name, proc_macro2::Span::call_site());
69        param_tokens.push(quote! {
70            (#name_lit = #ty, Path, description = #name_lit)
71        });
72    }
73
74    // Body args: non-path, non-receiver typed args (order matches Dioxus packing).
75    let body_fields: Vec<_> = arg_map
76        .iter()
77        .filter(|(name, _)| !path_params.iter().any(|p| p == name))
78        .map(|(name, (ty, _))| {
79            let ident = format_ident!("{}", name);
80            (ident, ty.clone())
81        })
82        .collect();
83
84    let has_body = method == "post" && !body_fields.is_empty() && args.request_body.is_none();
85    let request_body_override = args.request_body.clone();
86
87    let response_ty = match &args.response {
88        Some(ty) => Some(ty.clone()),
89        None => match extract_result_ok(&func.sig.output) {
90            Ok(ty) => ty,
91            Err(e) => return e.to_compile_error().into(),
92        },
93    };
94
95    let dioxus_attr = dioxus_route_attr(method, path_lit);
96    let method_ident = format_ident!("{}", method);
97
98    // Doc comments on the real fn — copy onto the openapi stub so utoipa
99    // picks up summary/description.
100    let docs: Vec<_> = func
101        .attrs
102        .iter()
103        .filter(|a| a.path().is_ident("doc"))
104        .cloned()
105        .collect();
106
107    // Strip nothing else; leave user attrs on the real function.
108    let _ = &mut func;
109
110    let body_struct_tokens = if has_body {
111        let fields = body_fields.iter().map(|(ident, ty)| {
112            quote! { pub #ident: #ty }
113        });
114        quote! {
115            #[cfg(feature = "server")]
116            #[derive(utoipa::ToSchema)]
117            #[allow(non_camel_case_types)]
118            #[doc(hidden)]
119            pub struct #body_struct {
120                #(#fields,)*
121            }
122        }
123    } else {
124        quote! {}
125    };
126
127    let request_body_tokens = if let Some(ty) = request_body_override {
128        quote! { request_body = #ty, }
129    } else if has_body {
130        quote! { request_body = #body_struct, }
131    } else {
132        quote! {}
133    };
134
135    let params_tokens = if param_tokens.is_empty() {
136        quote! {}
137    } else {
138        quote! { params( #(#param_tokens),* ), }
139    };
140
141    let responses_tokens = match response_ty {
142        None => quote! {
143            responses(
144                (status = 200, description = "OK"),
145                (status = 500, description = "Server error")
146            )
147        },
148        Some(Type::Tuple(t)) if t.elems.is_empty() => quote! {
149            responses(
150                (status = 200, description = "OK"),
151                (status = 500, description = "Server error")
152            )
153        },
154        Some(ty) => quote! {
155            responses(
156                (status = 200, description = "OK", body = #ty),
157                (status = 500, description = "Server error")
158            )
159        },
160    };
161
162    // utoipa names the Path impl struct `__path_{fn_name}`.
163    let path_type = format_ident!("__path_{}", openapi_fn);
164
165    quote! {
166        #dioxus_attr
167        #func
168
169        #body_struct_tokens
170
171        #[cfg(feature = "server")]
172        #(#docs)*
173        #[utoipa::path(
174            #method_ident,
175            path = #path_lit,
176            operation_id = #operation_id,
177            tag = #tag,
178            #params_tokens
179            #request_body_tokens
180            #responses_tokens
181        )]
182        #[doc(hidden)]
183        pub fn #openapi_fn() {}
184
185        // Auto-register with the dioxus-openapi inventory so apps do not need
186        // a hand-maintained paths(...) / schemas(...) list. Uses
187        // append_tagged_path so utoipa Tags land on the operation.
188        #[cfg(feature = "server")]
189        ::dioxus_openapi::inventory::submit! {
190            ::dioxus_openapi::RegisteredPath {
191                append: |paths| ::dioxus_openapi::append_tagged_path::<#path_type>(paths),
192                schemas: |schemas| {
193                    <#path_type as ::utoipa::__dev::SchemaReferences>::schemas(schemas);
194                },
195            }
196        }
197    }
198    .into()
199}
200
201fn dioxus_route_attr(method: &str, path: &LitStr) -> proc_macro2::TokenStream {
202    match method {
203        "get" => quote! { #[::dioxus::prelude::get(#path)] },
204        "post" => quote! { #[::dioxus::prelude::post(#path)] },
205        "put" => quote! { #[::dioxus::prelude::put(#path)] },
206        "delete" => quote! { #[::dioxus::prelude::delete(#path)] },
207        "patch" => quote! { #[::dioxus::prelude::patch(#path)] },
208        other => {
209            let msg = format!("unsupported method {other}");
210            quote! { compile_error!(#msg) }
211        }
212    }
213}
214
215struct ApiArgs {
216    path: LitStr,
217    tag: Option<LitStr>,
218    request_body: Option<Type>,
219    response: Option<Type>,
220    openapi: Option<bool>,
221}
222
223impl Parse for ApiArgs {
224    fn parse(input: ParseStream) -> syn::Result<Self> {
225        let path: LitStr = input.parse()?;
226        let mut tag = None;
227        let mut request_body = None;
228        let mut response = None;
229        let mut openapi = None;
230
231        if input.peek(Token![,]) {
232            let _ = input.parse::<Token![,]>()?;
233            let punct: Punctuated<Meta, Token![,]> = Punctuated::parse_terminated(input)?;
234            for meta in punct {
235                match meta {
236                    Meta::NameValue(nv) if nv.path.is_ident("tag") => {
237                        tag = Some(expr_to_lit_str(&nv.value)?);
238                    }
239                    Meta::NameValue(nv) if nv.path.is_ident("request_body") => {
240                        request_body = Some(expr_to_type(&nv.value)?);
241                    }
242                    Meta::NameValue(nv) if nv.path.is_ident("response") => {
243                        response = Some(expr_to_type(&nv.value)?);
244                    }
245                    Meta::NameValue(nv) if nv.path.is_ident("openapi") => {
246                        openapi = Some(expr_to_bool(&nv.value)?);
247                    }
248                    other => {
249                        return Err(syn::Error::new_spanned(
250                            other,
251                            "expected tag / request_body / response / openapi",
252                        ));
253                    }
254                }
255            }
256        }
257
258        Ok(Self {
259            path,
260            tag,
261            request_body,
262            response,
263            openapi,
264        })
265    }
266}
267
268fn expr_to_lit_str(expr: &Expr) -> syn::Result<LitStr> {
269    match expr {
270        Expr::Lit(syn::ExprLit {
271            lit: syn::Lit::Str(s),
272            ..
273        }) => Ok(s.clone()),
274        _ => Err(syn::Error::new_spanned(expr, "expected string literal")),
275    }
276}
277
278fn expr_to_bool(expr: &Expr) -> syn::Result<bool> {
279    match expr {
280        Expr::Lit(syn::ExprLit {
281            lit: syn::Lit::Bool(LitBool { value, .. }),
282            ..
283        }) => Ok(*value),
284        _ => Err(syn::Error::new_spanned(expr, "expected bool literal")),
285    }
286}
287
288fn expr_to_type(expr: &Expr) -> syn::Result<Type> {
289    // `response = Vec<Mac>` comes through as an expression path / etc.
290    // Re-parse the token stream as a Type.
291    syn::parse2(quote! { #expr })
292}
293
294/// `{serial}` / `{role}` captures in an axum-style path.
295fn parse_path_params(path: &str) -> Vec<String> {
296    let mut out = Vec::new();
297    let mut rest = path;
298    while let Some(start) = rest.find('{') {
299        rest = &rest[start + 1..];
300        let Some(end) = rest.find('}') else { break };
301        let name = &rest[..end];
302        // Skip wildcards like `{*path}` if ever used.
303        let name = name.strip_prefix('*').unwrap_or(name);
304        if !name.is_empty() {
305            out.push(name.to_string());
306        }
307        rest = &rest[end + 1..];
308    }
309    out
310}
311
312/// Map arg name → (type, original index). Skips receivers.
313fn fn_arg_map(func: &ItemFn) -> Vec<(String, (Type, usize))> {
314    // Use Vec to preserve order (important for body field order matching Dioxus).
315    let mut out = Vec::new();
316    for (i, arg) in func.sig.inputs.iter().enumerate() {
317        let FnArg::Typed(pat_ty) = arg else {
318            continue;
319        };
320        let Pat::Ident(pat_ident) = pat_ty.pat.as_ref() else {
321            continue;
322        };
323        out.push((
324            pat_ident.ident.to_string(),
325            ((*pat_ty.ty).clone(), i),
326        ));
327    }
328    out
329}
330
331/// `Result<T>` / `Result<T, E>` → `Some(T)`; bare non-Result is error for now.
332fn extract_result_ok(ret: &ReturnType) -> syn::Result<Option<Type>> {
333    let ReturnType::Type(_, ty) = ret else {
334        return Ok(None);
335    };
336    let Type::Path(type_path) = ty.as_ref() else {
337        return Ok(Some(ty.as_ref().clone()));
338    };
339    let last = type_path
340        .path
341        .segments
342        .last()
343        .ok_or_else(|| syn::Error::new_spanned(ty, "empty return type path"))?;
344    if last.ident != "Result" {
345        return Ok(Some(ty.as_ref().clone()));
346    }
347    let PathArguments::AngleBracketed(args) = &last.arguments else {
348        return Ok(None);
349    };
350    let mut generics = args.args.iter().filter_map(|a| match a {
351        GenericArgument::Type(t) => Some(t.clone()),
352        _ => None,
353    });
354    Ok(generics.next())
355}