Skip to main content

rovo_macros/
lib.rs

1#![warn(clippy::all)]
2#![warn(clippy::nursery)]
3#![warn(clippy::pedantic)]
4#![warn(missing_docs)]
5#![warn(rust_2018_idioms)]
6#![deny(unsafe_code)]
7// Allow some overly strict pedantic lints
8#![allow(clippy::too_many_lines)]
9#![allow(clippy::similar_names)]
10
11//! Procedural macros for the Rovo `OpenAPI` documentation framework.
12//!
13//! This crate provides the `#[rovo]` attribute macro that processes doc comments
14//! with special annotations to generate `OpenAPI` documentation automatically.
15
16use proc_macro::TokenStream;
17use quote::{quote, quote_spanned};
18
19mod parser;
20mod utils;
21
22use parser::{parse_rovo_function, PathParamDoc, PathParamInfo};
23
24/// Known primitive types that map to `OpenAPI` types
25const PRIMITIVE_TYPES: &[&str] = &[
26    "String", "u64", "u32", "u16", "u8", "i64", "i32", "i16", "i8", "bool", "Uuid",
27];
28
29/// Check if a type is a known primitive
30fn is_primitive_type(type_name: &str) -> bool {
31    PRIMITIVE_TYPES.contains(&type_name.trim())
32}
33
34/// Check if a tuple contains only primitives
35fn is_primitive_tuple(type_str: &str) -> bool {
36    let inner = type_str
37        .trim()
38        .trim_start_matches('(')
39        .trim_end_matches(')');
40    inner.split(',').map(str::trim).all(is_primitive_type)
41}
42
43/// Extract individual types from a tuple type string like "(Uuid, u32)"
44fn extract_tuple_types(type_str: &str) -> Vec<String> {
45    let inner = type_str
46        .trim()
47        .trim_start_matches('(')
48        .trim_end_matches(')');
49    inner
50        .split(',')
51        .map(|t| t.trim().to_string())
52        .filter(|s| !s.is_empty())
53        .collect()
54}
55
56/// Generate path parameter setters for primitive types
57fn generate_path_param_setters(
58    path_info: Option<&PathParamInfo>,
59    path_docs: &[PathParamDoc],
60) -> Vec<proc_macro2::TokenStream> {
61    let Some(info) = path_info else {
62        return vec![];
63    };
64
65    // If it's a struct pattern, let aide handle it via JsonSchema
66    if info.is_struct_pattern {
67        return vec![];
68    }
69
70    // Check if the type is primitive (single or tuple)
71    let is_primitive = if info.inner_type.starts_with('(') {
72        is_primitive_tuple(&info.inner_type)
73    } else {
74        is_primitive_type(&info.inner_type)
75    };
76
77    if !is_primitive {
78        return vec![];
79    }
80
81    // Extract types for each binding
82    let types: Vec<String> = if info.inner_type.starts_with('(') {
83        extract_tuple_types(&info.inner_type)
84    } else {
85        vec![info.inner_type.clone()]
86    };
87
88    // Generate a parameter setter for each binding
89    info.bindings
90        .iter()
91        .zip(types.iter())
92        .map(|(name, type_str)| {
93            // Find the description from docs
94            let description = path_docs
95                .iter()
96                .find(|doc| doc.name == *name)
97                .map(|doc| doc.description.clone());
98
99            let desc_setter = description.map_or_else(
100                || quote! { description: None, },
101                |desc| quote! { description: Some(#desc.to_string()), },
102            );
103
104            // Parse the type string to a TokenStream for use in generic context
105            let type_tokens: proc_macro2::TokenStream = type_str.parse().unwrap_or_else(|_| {
106                quote! { String }
107            });
108
109            quote! {
110                .with(|mut op| {
111                    op.inner_mut().parameters.push(
112                        ::rovo::aide::openapi::ReferenceOr::Item(
113                            ::rovo::aide::openapi::Parameter::Path {
114                                parameter_data: ::rovo::aide::openapi::ParameterData {
115                                    name: #name.to_string(),
116                                    #desc_setter
117                                    required: true,
118                                    deprecated: None,
119                                    format: ::rovo::aide::openapi::ParameterSchemaOrContent::Schema(
120                                        ::rovo::aide::openapi::SchemaObject {
121                                            json_schema: <#type_tokens as ::rovo::schemars::JsonSchema>::json_schema(
122                                                &mut ::rovo::schemars::SchemaGenerator::default()
123                                            ),
124                                            example: None,
125                                            external_docs: None,
126                                        }
127                                    ),
128                                    example: None,
129                                    examples: ::std::default::Default::default(),
130                                    explode: None,
131                                    extensions: ::std::default::Default::default(),
132                                },
133                                style: ::rovo::aide::openapi::PathStyle::Simple,
134                            }
135                        )
136                    );
137                    op
138                })
139            }
140        })
141        .collect()
142}
143
144/// Macro that generates `OpenAPI` documentation from doc comments.
145///
146/// This macro automatically generates `OpenAPI` documentation for your handlers
147/// using doc comments with special annotations.
148///
149/// # Documentation Format
150///
151/// Use Rust-style doc comment sections and metadata annotations:
152///
153/// ## Sections
154/// - `# Path Parameters` - Document path parameters for primitive types
155/// - `# Responses` - Document response status codes
156/// - `# Examples` - Provide example responses
157/// - `# Metadata` - Add tags, security, and other metadata
158///
159/// ## Path Parameters
160///
161/// For primitive path parameters (`String`, `u64`, `Uuid`, `bool`, etc.), you can
162/// document them directly without creating wrapper structs:
163///
164/// ```rust,ignore
165/// /// # Path Parameters
166/// ///
167/// /// user_id: The user's unique identifier
168/// /// index: Zero-based item index
169/// ```
170///
171/// The parameter names are inferred from the variable bindings in your function
172/// signature (e.g., `Path(user_id)` creates a parameter named `user_id`).
173///
174/// For complex types, continue using structs with `#[derive(JsonSchema)]`.
175///
176/// ## Metadata Annotations
177/// - `@tag <tag_name>` - Add a tag for grouping operations (can be used multiple times)
178/// - `@security <scheme_name>` - Add security requirements (can be used multiple times)
179/// - `@id <operation_id>` - Set a custom operation ID (defaults to function name)
180/// - `@hidden` - Hide this operation from documentation
181/// - `@rovo-ignore` - Stop processing annotations after this point
182///
183/// Additionally, the Rust `#[deprecated]` attribute is automatically detected
184/// and will mark the operation as deprecated in the `OpenAPI` spec.
185///
186/// # Examples
187///
188/// ## Primitive Path Parameter
189///
190/// ```rust,ignore
191/// /// Get user by ID.
192/// ///
193/// /// # Path Parameters
194/// ///
195/// /// id: The user's numeric identifier
196/// ///
197/// /// # Responses
198/// ///
199/// /// 200: Json<User> - User found
200/// /// 404: () - User not found
201/// #[rovo]
202/// async fn get_user(Path(id): Path<u64>) -> impl IntoApiResponse {
203///     // ...
204/// }
205/// ```
206///
207/// ## Tuple Path Parameters
208///
209/// ```rust,ignore
210/// /// Get item in collection.
211/// ///
212/// /// # Path Parameters
213/// ///
214/// /// collection_id: The collection UUID
215/// /// index: Item index within collection
216/// ///
217/// /// # Responses
218/// ///
219/// /// 200: Json<Item> - Item found
220/// #[rovo]
221/// async fn get_item(
222///     Path((collection_id, index)): Path<(Uuid, u32)>
223/// ) -> impl IntoApiResponse {
224///     // ...
225/// }
226/// ```
227///
228/// ## Struct-based Path (for complex types)
229///
230/// ```rust,ignore
231/// /// Get a single Todo item.
232/// ///
233/// /// Retrieve a Todo item by its ID from the database.
234/// ///
235/// /// # Responses
236/// ///
237/// /// 200: Json<TodoItem> - Successfully retrieved the todo item
238/// /// 404: () - Todo item was not found
239/// ///
240/// /// # Examples
241/// ///
242/// /// 200: TodoItem::default()
243/// ///
244/// /// # Metadata
245/// ///
246/// /// @tag todos
247/// #[rovo]
248/// async fn get_todo(
249///     State(app): State<AppState>,
250///     Path(todo): Path<SelectTodo>  // SelectTodo implements JsonSchema
251/// ) -> impl IntoApiResponse {
252///     // ...
253/// }
254/// ```
255///
256/// ## Deprecated Endpoint
257///
258/// ```rust,ignore
259/// /// This is a deprecated endpoint.
260/// ///
261/// /// # Metadata
262/// ///
263/// /// @tag admin
264/// /// @security bearer_auth
265/// #[deprecated]
266/// #[rovo]
267/// async fn old_handler() -> impl IntoApiResponse {
268///     // ...
269/// }
270/// ```
271#[proc_macro_attribute]
272pub fn rovo(_attr: TokenStream, item: TokenStream) -> TokenStream {
273    let input = item;
274
275    match parse_rovo_function(input.into()) {
276        Ok((func_item, doc_info)) => {
277            let func_name = &func_item.name;
278
279            let title = doc_info.title.as_deref().unwrap_or("");
280            let description = doc_info.description.as_deref().unwrap_or("");
281
282            // Generate response setters if we have doc comments
283            let response_code_setters = if doc_info.responses.is_empty() {
284                // No responses specified - generate a minimal docs function
285                vec![]
286            } else {
287                doc_info
288                    .responses
289                    .iter()
290                    .map(|resp| {
291                        let code = resp.status_code;
292                        let response_type = &resp.response_type;
293                        let desc = &resp.description;
294
295                        // Check if there's an explicit example for this status code
296                        doc_info
297                            .examples
298                            .iter()
299                            .find(|e| e.status_code == code)
300                            .map_or_else(
301                                || {
302                                    // No explicit example, just add the description
303                                    quote! {
304                                        .response_with::<#code, #response_type, _>(|res| {
305                                            res.description(#desc)
306                                        })
307                                    }
308                                },
309                                |example| {
310                                    let example_code = &example.example_code;
311                                    quote! {
312                                        .response_with::<#code, #response_type, _>(|res| {
313                                            res.description(#desc)
314                                                .example(#example_code)
315                                        })
316                                    }
317                                },
318                            )
319                    })
320                    .collect()
321            };
322
323            // Generate tag setters
324            let tag_setters: Vec<_> = doc_info
325                .tags
326                .iter()
327                .map(|tag| {
328                    quote! { .tag(#tag) }
329                })
330                .collect();
331
332            // Generate security requirement setters
333            let security_setters: Vec<_> = doc_info
334                .security_requirements
335                .iter()
336                .map(|scheme| {
337                    quote! { .security_requirement(#scheme) }
338                })
339                .collect();
340
341            // Generate operation ID setter
342            let operation_id_setter = doc_info.operation_id.as_ref().map_or_else(
343                || {
344                    // Default to function name if no custom ID provided
345                    let default_id = func_name.to_string();
346                    quote! { .id(#default_id) }
347                },
348                |id| quote! { .id(#id) },
349            );
350
351            // Generate deprecated setter
352            let deprecated_setter = if doc_info.deprecated {
353                quote! { .with(|mut op| { op.inner_mut().deprecated = true; op }) }
354            } else {
355                quote! {}
356            };
357
358            // Generate hidden setter
359            let hidden_setter = if doc_info.hidden {
360                quote! { .hidden(true) }
361            } else {
362                quote! {}
363            };
364
365            // Generate path parameter setters for primitive types
366            let path_param_setters =
367                generate_path_param_setters(func_item.path_params.as_ref(), &doc_info.path_params);
368
369            // Generate an internal implementation name
370            let impl_name = quote::format_ident!("__{}_impl", func_name);
371
372            // Get the renamed function tokens
373            let impl_func = func_item.with_renamed(&impl_name);
374
375            // Create a const with an uppercase version of the handler name
376            let const_name = quote::format_ident!("{}", func_name.to_string().to_uppercase());
377
378            // Determine the state type for the trait implementation
379            let state_type = func_item
380                .state_type
381                .as_ref()
382                .map_or_else(|| quote! { () }, |st| quote! { #st });
383
384            let output = quote! {
385                // Internal implementation with renamed function
386                #[allow(non_snake_case, private_interfaces)]
387                #impl_func
388
389                // Create a zero-sized type that can be passed to routing functions
390                #[allow(non_camel_case_types)]
391                #[derive(Clone, Copy)]
392                pub struct #func_name;
393
394                impl #func_name {
395                    #[doc(hidden)]
396                    pub fn __docs(op: ::rovo::aide::transform::TransformOperation) -> ::rovo::aide::transform::TransformOperation {
397                        op
398                            #operation_id_setter
399                            .summary(#title)
400                            .description(#description)
401                            #(#tag_setters)*
402                            #deprecated_setter
403                            #hidden_setter
404                            #(#security_setters)*
405                            #(#path_param_setters)*
406                            #(#response_code_setters)*
407                    }
408                }
409
410                // Implement the IntoApiMethodRouter trait
411                impl ::rovo::IntoApiMethodRouter<#state_type> for #func_name {
412                    fn into_get_route(self) -> ::rovo::aide::axum::routing::ApiMethodRouter<#state_type> {
413                        ::rovo::aide::axum::routing::get_with(#impl_name, Self::__docs)
414                    }
415
416                    fn into_post_route(self) -> ::rovo::aide::axum::routing::ApiMethodRouter<#state_type> {
417                        ::rovo::aide::axum::routing::post_with(#impl_name, Self::__docs)
418                    }
419
420                    fn into_patch_route(self) -> ::rovo::aide::axum::routing::ApiMethodRouter<#state_type> {
421                        ::rovo::aide::axum::routing::patch_with(#impl_name, Self::__docs)
422                    }
423
424                    fn into_delete_route(self) -> ::rovo::aide::axum::routing::ApiMethodRouter<#state_type> {
425                        ::rovo::aide::axum::routing::delete_with(#impl_name, Self::__docs)
426                    }
427
428                    fn into_put_route(self) -> ::rovo::aide::axum::routing::ApiMethodRouter<#state_type> {
429                        ::rovo::aide::axum::routing::put_with(#impl_name, Self::__docs)
430                    }
431                }
432
433                // Also create a CONST for explicit use
434                #[allow(non_upper_case_globals)]
435                pub const #const_name: #func_name = #func_name;
436            };
437
438            output.into()
439        }
440        Err(err) => {
441            let err_msg = err.to_string();
442            // Use the span from the error if available, otherwise use call_site
443            let error_tokens = err.span().map_or_else(
444                || {
445                    quote! {
446                        compile_error!(#err_msg);
447                    }
448                },
449                |span| {
450                    quote_spanned! {span=>
451                        compile_error!(#err_msg);
452                    }
453                },
454            );
455            error_tokens.into()
456        }
457    }
458}