OpenAPI documentation for Axum, written as doc comments.
Document an endpoint in the doc comment above the handler, add #[rovo], and keep routing exactly as you would in axum. Request bodies, query parameters and schemas are picked up from your extractors and types; rovo checks the doc comment at compile time.
/// Get a user by ID.
///
/// # Path Parameters
///
/// id: The user's ID
///
/// # Responses
///
/// 200: Json<User> - User found
/// 404: () - User not found
#[rovo]
async fn get_user(Path(id): Path<u64>) -> impl IntoApiResponse { /* ... */ }
let app = Router::new().route("/users/{id}", get(get_user));Features
- Drop-in replacement for
axum::Router, with the same.route(path, get(handler))syntax - Doc-comment driven documentation, readable as plain rustdoc
- Compile-time validation of annotations
- Request bodies (
Json,Form,Multipart) and query parameters documented from extractors - Built-in Swagger/Redoc/Scalar UI integration
- Type-safe response definitions
- Minimal runtime overhead
- Editor Integration (completions, hover docs and diagnostics for doc annotations):
- VSCode/VSCodium - See VSCODE.md for extension with auto-installation, completions, and syntax highlighting
- Neovim LSP - See NEOVIM.md for editor support with completions, hover docs, and more
- JetBrains IDEs - See JETBRAINS.md for RustRover, IntelliJ IDEA, and CLion support
- Zed - See ZED.md for full Zed editor integration
Quick Start
use rovo::prelude::*;
use serde::Serialize;
#[derive(Clone)]
struct AppState {}
#[derive(Serialize, JsonSchema)]
struct User {
id: u64,
name: String,
}
/// Get user information.
///
/// Returns the current user's profile information.
///
/// # Responses
///
/// 200: Json<User> - User profile retrieved successfully
///
/// # Metadata
///
/// @tag users
#[rovo]
async fn get_user(State(_state): State<AppState>) -> impl IntoApiResponse {
Json(User {
id: 1,
name: "Alice".to_string(),
})
}
#[tokio::main]
async fn main() {
let state = AppState {};
let mut api = OpenApi::default();
api.info.title = "My API".to_string();
let app = Router::new()
.route("/user", get(get_user))
.with_oas(api)
.with_swagger("/")
.with_state(state)
.finish();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}Installation
[dependencies]
rovo = { version = "0.4.14", features = ["swagger"] }
axum = "0.8"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }Note:
rovo::prelude::*brings in the macro, router, routing functions and the common extractors and response types. Rovo also re-exportsaide,schemars, and axum'sextract,responseandhttpmodules, so you don't need them as direct dependencies. Theaxumdependency is still needed foraxum::serve().
For detailed API documentation, see docs.rs/rovo.
Using an AI coding assistant? Point it at llms.txt for a compact reference, or llms-full.txt for the README and all examples in one file. Both are also shipped in the published crate.
schemars version: rovo uses schemars 0.9, because aide 0.15 does. If your project also uses schemars 1.x, derive
rovo::schemars::JsonSchemaon the types your handlers return; see Troubleshooting.
Feature Flags
Choose one or more documentation UIs (none enabled by default):
swagger- Swagger UIredoc- Redoc UIscalar- Scalar UI
Optional extractor support:
multipart- Document handlers that takeaxum::extract::Multipartws- Allow handlers that takeaxum::extract::WebSocketUpgrade
Compared to utoipa and aide
The same endpoint in each library: a GET /users/{id} with a described path parameter, a tag and two responses.
utoipa (with utoipa-axum):
#[derive(Serialize, ToSchema)]
struct User { id: u64, name: String }
/// Get a user by ID.
#[utoipa::path(
get,
path = "/users/{id}",
tag = "users",
params(("id" = u64, Path, description = "The user's ID")),
responses(
(status = 200, description = "User found", body = User),
(status = 404, description = "User not found"),
),
)]
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, StatusCode> { /* ... */ }
let (router, api) = OpenApiRouter::new()
.routes(routes!(get_user))
.split_for_parts();aide:
#[derive(Serialize, JsonSchema)]
struct User { id: u64, name: String }
#[derive(Deserialize, JsonSchema)]
struct UserPath {
/// The user's ID
id: u64,
}
async fn get_user(Path(UserPath { id }): Path<UserPath>) -> impl IntoApiResponse { /* ... */ }
fn get_user_docs(op: TransformOperation) -> TransformOperation {
op.description("Get a user by ID.")
.tag("users")
.response_with::<200, Json<User>, _>(|res| res.description("User found"))
.response_with::<404, (), _>(|res| res.description("User not found"))
}
let router = ApiRouter::new()
.api_route("/users/{id}", get_with(get_user, get_user_docs));rovo:
#[derive(Serialize, JsonSchema)]
struct User { id: u64, name: String }
/// Get a user by ID.
///
/// # Path Parameters
///
/// id: The user's ID
///
/// # Responses
///
/// 200: Json<User> - User found
/// 404: () - User not found
///
/// # Metadata
///
/// @tag users
#[rovo]
async fn get_user(Path(id): Path<u64>) -> impl IntoApiResponse { /* ... */ }
let router = Router::new().route("/users/{id}", get(get_user));| utoipa | aide | rovo | |
|---|---|---|---|
| Where docs live | #[utoipa::path(...)] attribute |
A separate TransformOperation function |
The handler's doc comment |
| Routing | OpenApiRouter + routes! |
api_route + get_with |
axum's .route(path, get(handler)) |
| Frameworks | axum, actix-web, rocket and more | axum | axum |
| Docs checked at compile time | Yes | Yes (plain Rust code) | Yes |
| Editor support for the docs | Standard Rust tooling | Standard Rust tooling | Language server with completions and hover |
| Maturity | Most widely used | Established | Pre-1.0, smaller community |
Rovo is built on aide, so everything aide generates (schemas, request bodies, query parameters) is available, and Router::into_inner() hands you the underlying aide ApiRouter when you need something rovo doesn't cover.
Pick utoipa if you're not on axum, or you want the largest ecosystem. Pick aide if you prefer documentation as plain Rust function calls. Pick rovo if you're on axum and want your API docs to read like normal doc comments next to the handler.
Documentation Format
Rovo uses Rust-style documentation with markdown sections and metadata annotations.
Complete Example
/// Get a todo item by ID.
///
/// Retrieves a single todo item from the database. Returns 404
/// if the item doesn't exist.
///
/// # Path Parameters
///
/// id: The todo item's unique identifier
///
/// # Responses
///
/// 200: Json<TodoItem> - Successfully retrieved the todo item
/// 404: () - Todo item was not found
/// 500: Json<ErrorResponse> - Internal server error
///
/// # Examples
///
/// 200: TodoItem {
/// title: "Buy milk".into(),
/// ..Default::default()
/// }
/// 404: ()
///
/// # Metadata
///
/// @tag todos
/// @security bearer_auth
#[rovo]
async fn get_todo(Path(id): Path<i32>) -> impl IntoApiResponse {
// ...
}Responses Section
Document HTTP responses with status codes, types, and descriptions:
/// # Responses
///
/// 200: Json<User> - User found successfully
/// 404: () - User not found
/// 500: Json<ErrorResponse> - Internal server errorFormat: <status_code>: <type> - <description>
- Status codes must be valid HTTP codes (100-599)
- Type must be valid Rust syntax
- Description explains when this response occurs
Path Parameters Section
Document path parameters for primitive types:
/// # Path Parameters
///
/// id: The user's unique identifier
/// index: Zero-based item indexFormat: <name>: <description>
- Parameter names must match the variable bindings in your function signature
- Works with primitives:
String,u64,u32,i64,i32,bool,Uuid, etc. - For tuple paths like
Path((a, b)): Path<(Uuid, u32)>, document each parameter
Examples Section
Provide concrete response examples:
/// # Examples
///
/// 200: User { id: 1, name: "Alice".into(), email: "alice@example.com".into() }
/// 404: ()Format: <status_code>: <rust_expression>
Examples should match the types defined in the Responses section.
Metadata Section
Contains API metadata using @ annotations:
@tag
Group operations by tags (can be used multiple times):
/// # Metadata
///
/// @tag users
/// @tag authentication@security
Specify security requirements (can be used multiple times):
/// # Metadata
///
/// @security bearer_authSecurity schemes must be defined in your OpenAPI spec. See Tips for details.
@id
Set custom operation ID (defaults to function name):
/// # Metadata
///
/// @id getUserByIdMust contain only alphanumeric characters and underscores.
@hidden
Hide an operation from documentation:
/// # Metadata
///
/// @hiddenSpecial Directives
#[deprecated]
Mark endpoints as deprecated using Rust's built-in attribute:
#[deprecated]
#[rovo]
async fn old_handler() -> impl IntoApiResponse {
// ...
}@rovo-ignore
Stop processing annotations after this point (location-independent):
/// Get user information.
///
/// # Responses
///
/// 200: Json<User> - User found successfully
///
/// # Metadata
///
/// @tag users
///
/// @rovo-ignore
///
/// Additional documentation here won't be processed.
/// You can write @anything without causing errors.
#[rovo]
async fn handler() -> impl IntoApiResponse {
// ...
}Request Inputs
You don't document request inputs in the doc comment: rovo reads them from the handler's extractors.
| Extractor | Documented as |
|---|---|
Json<T> |
application/json request body with T's schema |
Form<T> |
application/x-www-form-urlencoded request body with T's schema |
Multipart (feature multipart) |
multipart/form-data request body |
Query<T> |
One query parameter per field of T |
Path<T> |
Path parameters (see Path Parameters) |
State<T>, HeaderMap |
Nothing (not part of the API surface) |
Doc comments on struct fields become parameter and property descriptions, and Option<_> fields are marked as not required:
#[derive(Deserialize, JsonSchema)]
struct SearchParams {
/// Maximum number of results to return
limit: Option<u32>,
/// Only return users whose name contains this string
name: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct CreateUser {
/// Display name
name: String,
}
/// Search users.
///
/// # Responses
///
/// 200: Json<Vec<User>> - Matching users
#[rovo]
async fn search_users(Query(params): Query<SearchParams>) -> impl IntoApiResponse {
// documented as GET with `limit` and `name` query parameters
}
/// Create a user.
///
/// # Responses
///
/// 201: Json<User> - User created
/// 422: Json<ErrorResponse> - Invalid input
#[rovo]
async fn create_user(Json(body): Json<CreateUser>) -> impl IntoApiResponse {
// documented with a JSON request body
}Headers read through HeaderMap aren't added to the spec. For authentication headers, declare a security scheme and reference it with @security.
Schemas come from schemars, so serde attributes are respected. For example, an enum with #[serde(tag = "type")] becomes a oneOf with one object per variant.
Custom extractors and error types
Every extractor in a #[rovo] handler must implement aide::OperationInput, and every return type aide::OperationOutput. For your own types, an empty OperationInput impl and an OperationOutput impl naming the response body are usually enough, since the # Responses section documents the actual responses:
use rovo::aide::{OperationInput, OperationOutput};
struct AuthUser { /* ... */ }
impl FromRequestParts<AppState> for AuthUser { /* ... */ }
impl OperationInput for AuthUser {}
enum ApiError { /* ... */ }
impl IntoResponse for ApiError { /* ... */ }
impl OperationOutput for ApiError {
type Inner = ErrorBody;
}#[rovo] takes the router's state type from a State<T> parameter, or uses () if there is none. If a custom extractor needs state but the handler has no State<T> parameter, name the state type in the attribute:
#[rovo(state = AppState)]
async fn me(user: AuthUser) -> Result<Json<Profile>, ApiError> {
// ...
}See examples/auth_api.rs for a complete bearer-token example.
Router API
Basic Usage
use rovo::Router;
let app = Router::new()
.route("/path", get(handler))
.with_state(state)
.finish();Method Chaining
use rovo::routing::{get, post, patch, delete};
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item).patch(update_item).delete(delete_item))Nesting Routes
Router::new()
.nest(
"/api",
Router::new()
.route("/users", get(list_users))
.route("/posts", get(list_posts))
)Nest routers with different state types by calling .with_state() on each inner router:
let app = Router::new()
.nest(
"/api",
Router::new()
.route("/todos", get(list_todos))
.with_state(todo_state),
)
.nest(
"/meta",
Router::new()
.route("/health", get(health_check))
.with_state(meta_state),
)
.with_oas(api)
.finish();Documentation UIs
Router::new()
.route("/users", get(list_users))
.with_oas(api)
.with_swagger("/swagger")
.with_redoc("/redoc")
.with_scalar("/scalar")
.with_state(state)
.finish()Use custom OAS route:
Router::new()
.route("/users", get(list_users))
.with_oas_route(api, "/openapi")
.with_swagger("/")
.with_state(state)
.finish()OpenAPI Formats
Rovo automatically serves your OpenAPI specification in multiple formats:
- JSON -
/api.json(default) - YAML -
/api.yamlor/api.yml
All formats are automatically available when you use .with_oas() or .with_oas_route().
Examples
| Example | Shows |
|---|---|
| hello_world | The smallest documented handler, as a standalone crate |
| todo_api | A CRUD API with path parameters and nested routers with different state types |
| auth_api | Bearer-token auth with a custom extractor, a custom error type, form login and @security |
| catalog_api | Query-string pagination, a generic Page<T> response, a tagged enum (oneOf) and a multipart upload (needs -F multipart) |
Run one with:
cargo run -F swagger --example todo_apiMigration from Axum
Replace imports and add documentation:
// Before
use axum::{Router, response::IntoResponse, routing::get};
async fn handler() -> impl IntoResponse {
Json(data)
}
// After
use rovo::prelude::*;
/// Handler description
///
/// # Responses
///
/// 200: Json<Data> - Success
///
/// # Metadata
///
/// @tag category
#[rovo]
async fn handler() -> impl IntoApiResponse {
Json(data)
}Add OpenAPI setup in main():
let mut api = OpenApi::default();
api.info.title = "My API".to_string();
let app = Router::new()
.route("/path", get(handler))
.with_oas(api)
.with_swagger("/")
.with_state(state)
.finish();Tips
Path Parameters
For primitive types (String, u64, Uuid, bool, etc.), document parameters directly in doc comments:
/// Get user by ID.
///
/// # Path Parameters
///
/// id: The user's unique identifier
///
/// # Responses
///
/// 200: Json<User> - User found
#[rovo]
async fn get_user(Path(id): Path<u64>) -> impl IntoApiResponse {
// ...
}For tuple parameters:
/// Get item in collection.
///
/// # Path Parameters
///
/// collection_id: The collection UUID
/// index: Item index
///
/// # Responses
///
/// 200: Json<Item> - Item found
#[rovo]
async fn get_item(Path((collection_id, index)): Path<(Uuid, u32)>) -> impl IntoApiResponse {
// ...
}For complex types, use structs with JsonSchema:
use rovo::schemars::JsonSchema;
use serde::Deserialize;
use uuid::Uuid;
#[derive(Deserialize, JsonSchema)]
struct UserId {
/// The user's unique identifier
id: Uuid,
}
#[rovo]
async fn get_user(Path(UserId { id }): Path<UserId>) -> impl IntoApiResponse {
// ...
}Security Schemes
Define in OpenAPI object:
use rovo::aide::openapi::{ReferenceOr, SecurityScheme};
api.components.get_or_insert_default().security_schemes.insert(
"bearer_auth".to_string(),
ReferenceOr::Item(SecurityScheme::Http {
scheme: "bearer".to_string(),
bearer_format: Some("JWT".to_string()),
description: None,
extensions: Default::default(),
}),
);Reference in handlers:
/// Protected endpoint requiring authentication.
///
/// # Responses
///
/// 200: Json<Data> - Success
/// 401: () - Unauthorized
///
/// # Metadata
///
/// @security bearer_auth
#[rovo]
async fn protected_handler() -> impl IntoApiResponse {
// ...
}Troubleshooting
Handler doesn't implement required traits
Add the #[rovo] macro:
#[rovo]
async fn handler() -> impl IntoApiResponse {
// ...
}JsonSchema is not implemented with schemars 1.x in the project
error[E0277]: the trait bound `User: rovo::schemars::JsonSchema` is not satisfied
note: there are multiple different versions of crate `schemars` in the dependency graphrovo uses schemars 0.9 (through aide 0.15), so a type that only derives schemars::JsonSchema from schemars 1.x doesn't implement the trait rovo needs. Both versions can live in one project, and one type can derive both:
#[derive(Serialize, rovo::schemars::JsonSchema, schemars::JsonSchema)]
struct User {
/// Shared `#[schemars(...)]` attributes apply to both derives.
#[schemars(range(min = 1))]
id: u64,
}Using different state types
Call .with_state() on each nested router individually, then .finish() at the top level (see Nesting Routes).
Development
This project uses just for common development tasks.
Quick Start
# List all available commands
just
# Run all checks (format, clippy, tests)
just check
# Fix formatting and clippy issues
just fix
# Run tests
just test
# Check for outdated dependencies
just outdated
# Check for unused dependencies
just unused-deps
# Check for security vulnerabilities
just auditPre-commit Hooks
Uses prek for git hooks:
prek install
prek run # Run manuallyAvailable Commands
just test- Run all testsjust lint- Run clippy lintsjust fmt- Format codejust check- Run all checks (fmt, clippy, test)just fix- Run all checks and fixesjust build- Build the projectjust example- Run the todo_api examplejust outdated- Check for outdated dependenciesjust unused-deps- Check for unused dependenciesjust audit- Check for security vulnerabilitiesjust docs- Build and open documentationjust pre-release- Run all pre-release checks
See just --list for all available commands.
Contributing
Contributions are welcome. Please submit a Pull Request.
License
MIT