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}