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;
23
24/// Macro that generates `OpenAPI` documentation from doc comments.
25///
26/// This macro automatically generates `OpenAPI` documentation for your handlers
27/// using doc comments with special annotations.
28///
29/// # Documentation Format
30///
31/// Use Rust-style doc comment sections and metadata annotations:
32///
33/// ## Sections
34/// - `# Responses` - Document response status codes
35/// - `# Examples` - Provide example responses
36/// - `# Metadata` - Add tags, security, and other metadata
37///
38/// ## Metadata Annotations
39/// - `@tag <tag_name>` - Add a tag for grouping operations (can be used multiple times)
40/// - `@security <scheme_name>` - Add security requirements (can be used multiple times)
41/// - `@id <operation_id>` - Set a custom operation ID (defaults to function name)
42/// - `@hidden` - Hide this operation from documentation
43/// - `@rovo-ignore` - Stop processing annotations after this point
44///
45/// Additionally, the Rust `#[deprecated]` attribute is automatically detected
46/// and will mark the operation as deprecated in the `OpenAPI` spec.
47///
48/// # Example
49///
50/// ```rust,ignore
51/// /// Get a single Todo item.
52/// ///
53/// /// Retrieve a Todo item by its ID from the database.
54/// ///
55/// /// # Responses
56/// ///
57/// /// 200: Json<TodoItem> - Successfully retrieved the todo item
58/// /// 404: () - Todo item was not found
59/// ///
60/// /// # Examples
61/// ///
62/// /// 200: TodoItem::default()
63/// ///
64/// /// # Metadata
65/// ///
66/// /// @tag todos
67/// #[rovo]
68/// async fn get_todo(
69/// State(app): State<AppState>,
70/// Path(todo): Path<SelectTodo>
71/// ) -> impl IntoApiResponse {
72/// // ...
73/// }
74///
75/// /// This is a deprecated endpoint.
76/// ///
77/// /// # Metadata
78/// ///
79/// /// @tag admin
80/// /// @security bearer_auth
81/// #[deprecated]
82/// #[rovo]
83/// async fn old_handler() -> impl IntoApiResponse {
84/// // ...
85/// }
86/// ```
87#[proc_macro_attribute]
88pub fn rovo(_attr: TokenStream, item: TokenStream) -> TokenStream {
89 let input = item;
90
91 match parse_rovo_function(input.into()) {
92 Ok((func_item, doc_info)) => {
93 let func_name = &func_item.name;
94
95 let title = doc_info.title.as_deref().unwrap_or("");
96 let description = doc_info.description.as_deref().unwrap_or("");
97
98 // Generate response setters if we have doc comments
99 let response_code_setters = if doc_info.responses.is_empty() {
100 // No responses specified - generate a minimal docs function
101 vec![]
102 } else {
103 doc_info
104 .responses
105 .iter()
106 .map(|resp| {
107 let code = resp.status_code;
108 let response_type = &resp.response_type;
109 let desc = &resp.description;
110
111 // Check if there's an explicit example for this status code
112 doc_info
113 .examples
114 .iter()
115 .find(|e| e.status_code == code)
116 .map_or_else(
117 || {
118 // No explicit example, just add the description
119 quote! {
120 .response_with::<#code, #response_type, _>(|res| {
121 res.description(#desc)
122 })
123 }
124 },
125 |example| {
126 let example_code = &example.example_code;
127 quote! {
128 .response_with::<#code, #response_type, _>(|res| {
129 res.description(#desc)
130 .example(#example_code)
131 })
132 }
133 },
134 )
135 })
136 .collect()
137 };
138
139 // Generate tag setters
140 let tag_setters: Vec<_> = doc_info
141 .tags
142 .iter()
143 .map(|tag| {
144 quote! { .tag(#tag) }
145 })
146 .collect();
147
148 // Generate security requirement setters
149 let security_setters: Vec<_> = doc_info
150 .security_requirements
151 .iter()
152 .map(|scheme| {
153 quote! { .security_requirement(#scheme) }
154 })
155 .collect();
156
157 // Generate operation ID setter
158 let operation_id_setter = doc_info.operation_id.as_ref().map_or_else(
159 || {
160 // Default to function name if no custom ID provided
161 let default_id = func_name.to_string();
162 quote! { .id(#default_id) }
163 },
164 |id| quote! { .id(#id) },
165 );
166
167 // Generate deprecated setter
168 let deprecated_setter = if doc_info.deprecated {
169 quote! { .with(|mut op| { op.inner_mut().deprecated = true; op }) }
170 } else {
171 quote! {}
172 };
173
174 // Generate hidden setter
175 let hidden_setter = if doc_info.hidden {
176 quote! { .hidden(true) }
177 } else {
178 quote! {}
179 };
180
181 // Generate an internal implementation name
182 let impl_name = quote::format_ident!("__{}_impl", func_name);
183
184 // Get the renamed function tokens
185 let impl_func = func_item.with_renamed(&impl_name);
186
187 // Create a const with an uppercase version of the handler name
188 let const_name = quote::format_ident!("{}", func_name.to_string().to_uppercase());
189
190 // Determine the state type for the trait implementation
191 let state_type = func_item
192 .state_type
193 .as_ref()
194 .map_or_else(|| quote! { () }, |st| quote! { #st });
195
196 let output = quote! {
197 // Internal implementation with renamed function
198 #[allow(non_snake_case, private_interfaces)]
199 #impl_func
200
201 // Create a zero-sized type that can be passed to routing functions
202 #[allow(non_camel_case_types)]
203 #[derive(Clone, Copy)]
204 pub struct #func_name;
205
206 impl #func_name {
207 #[doc(hidden)]
208 pub fn __docs(op: aide::transform::TransformOperation) -> aide::transform::TransformOperation {
209 op
210 #operation_id_setter
211 .summary(#title)
212 .description(#description)
213 #(#tag_setters)*
214 #deprecated_setter
215 #hidden_setter
216 #(#security_setters)*
217 #(#response_code_setters)*
218 }
219 }
220
221 // Implement the IntoApiMethodRouter trait
222 impl ::rovo::IntoApiMethodRouter<#state_type> for #func_name {
223 fn into_get_route(self) -> aide::axum::routing::ApiMethodRouter<#state_type> {
224 aide::axum::routing::get_with(#impl_name, Self::__docs)
225 }
226
227 fn into_post_route(self) -> aide::axum::routing::ApiMethodRouter<#state_type> {
228 aide::axum::routing::post_with(#impl_name, Self::__docs)
229 }
230
231 fn into_patch_route(self) -> aide::axum::routing::ApiMethodRouter<#state_type> {
232 aide::axum::routing::patch_with(#impl_name, Self::__docs)
233 }
234
235 fn into_delete_route(self) -> aide::axum::routing::ApiMethodRouter<#state_type> {
236 aide::axum::routing::delete_with(#impl_name, Self::__docs)
237 }
238
239 fn into_put_route(self) -> aide::axum::routing::ApiMethodRouter<#state_type> {
240 aide::axum::routing::put_with(#impl_name, Self::__docs)
241 }
242 }
243
244 // Also create a CONST for explicit use
245 #[allow(non_upper_case_globals)]
246 pub const #const_name: #func_name = #func_name;
247 };
248
249 output.into()
250 }
251 Err(err) => {
252 let err_msg = err.to_string();
253 // Use the span from the error if available, otherwise use call_site
254 let error_tokens = err.span().map_or_else(
255 || {
256 quote! {
257 compile_error!(#err_msg);
258 }
259 },
260 |span| {
261 quote_spanned! {span=>
262 compile_error!(#err_msg);
263 }
264 },
265 );
266 error_tokens.into()
267 }
268 }
269}