
Complete Guide to Utoipa Integration with Axum
Learn how to integrate utoipa and Swagger UI in Rust Axum projects to enable OpenAPI documentation with JWT, Cookie, and API Key authentication.
Jing Zhong
Building RESTful APIs in Rust with Axum is fantastic, but comprehensive API documentation is equally important. That's where utoipa comes in - a powerful OpenAPI documentation generator that integrates seamlessly with Axum and Swagger UI. Let's dive into how to set up a complete documentation system with multiple authentication methods.
Why Utoipa?
utoipa provides compile-time OpenAPI documentation generation using Rust macros:
- Type Safety: Leverages Rust's type system for accurate API docs
- No Code Duplication: Documentation lives alongside your code
- Multiple Auth Methods: Support for JWT, API Keys, Cookies
- Swagger UI Integration: Beautiful, interactive API documentation
- OpenAPI 3.0 Compatible: Standard-compliant specification
Project Setup
Dependency Configuration
Start by adding the necessary dependencies to your Cargo.toml:
[dependencies]
# Core dependencies
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sea-orm = "0.12"
# OpenAPI documentation
utoipa = { version = "4", features = ["uuid", "chrono"] }
utoipa-swagger-ui = { version = "6", features = ["axum"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Time and UUID
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
Important Notes:
- Use utoipa version 4.x (avoid 5.x for better stability)
- Enable
uuidandchronofeatures for proper type support - Ensure
utoipa-swagger-uiversion is compatible withutoipa
Project Structure
Organize your documentation code for clarity:
src/
├── docs/
│ ├── mod.rs # Documentation module entry
│ ├── security.rs # Security schemes (Modify trait)
│ ├── openapi.rs # OpenAPI helpers
│ └── schemas.rs # Data model definitions
├── handler.rs # API handlers
├── main.rs # App entry + ApiDoc definition
└── route.rs # Route definitions
Understanding Utoipa's Architecture
utoipa generates OpenAPI documentation through three key components:
┌─────────────────────────────────────────────────┐
│ utoipa::path (marks handler functions) │
│ ↓ │
│ Collects API info (path, method, params, resp) │
│ │
├─────────────────────────────────────────────────┤
│ ToSchema (marks data structures) │
│ ↓ │
│ Generates Schema definitions (request/response)│
│ │
├─────────────────────────────────────────────────┤
│ OpenApi (aggregates configuration) │
│ ↓ │
│ Generates complete OpenAPI JSON document │
│ │
├─────────────────────────────────────────────────┤
│ SwaggerUi (serves documentation interface) │
│ ↓ │
│ Provides visual API docs and testing UI │
└─────────────────────────────────────────────────┘
Security Configuration: The Modify Trait
The Challenge
utoipa 4.x doesn't support defining security schemes directly in the #[openapi] macro:
// ❌ This doesn't work in utoipa 4.x!
#[derive(OpenApi)]
#[openapi(
security_schemes(
("bearerAuth" = ...)
)
)]
struct ApiDoc;
The Solution: Modify Trait
Create a custom type that implements the Modify trait:
// src/docs/security.rs
use utoipa::{Modify, OpenApi};
use utoipa::openapi::security::{SecurityScheme, HttpAuthScheme, Http, ApiKey, ApiKeyValue};
/// JWT and API Key security configuration
pub struct JwtSecurityAddon;
impl Modify for JwtSecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
// 1. JWT Bearer Token authentication
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(
Http::new(HttpAuthScheme::Bearer)
),
);
// 2. Cookie authentication
components.add_security_scheme(
"cookieAuth",
SecurityScheme::ApiKey(
ApiKey::Cookie(ApiKeyValue::new("token"))
),
);
// 3. API Key authentication (Header-based)
components.add_security_scheme(
"apiKeyAuth",
SecurityScheme::ApiKey(
ApiKey::Header(ApiKeyValue::new("X-API-Key"))
),
);
}
}
}
Then use it in your ApiDoc:
// src/main.rs
use acrospire_auth::docs::JwtSecurityAddon;
#[derive(OpenApi)]
#[openapi(
modifiers(&JwtSecurityAddon) // ← Key! Use modifiers to add security schemes
)]
struct ApiDoc;
Authentication Methods Explained
1. Bearer Token (JWT)
- Type: HTTP Authentication
- Scheme: Bearer
- Usage: JWT Token authentication
- Client: Enter JWT Token directly in Swagger UI (no "Bearer " prefix needed)
2. Cookie Authentication
- Type: API Key
- Location: Cookie
- Name: token
- Usage: Cookie-based authentication, browser auto-carries cookie
3. API Key (Header)
- Type: API Key
- Location: Header
- Name: X-API-Key
- Usage: API Key authentication (e.g.,
ak_live_xxxxxx)
Defining API Paths
Basic Syntax
use utoipa::path;
#[utoipa::path(
// HTTP method and path
post,
path = "/api/auth/login",
// Grouping tag
tag = "Authentication",
// Request body
request_body = LoginRequest,
// Authentication requirements
security(
("bearerAuth" = []),
("cookieAuth" = [])
),
// Response definitions
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 401, description = "Authentication failed", body = ErrorResponse)
)
)]
pub async fn login_handler(
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
// Handler implementation
}
Public Endpoint (No Authentication)
/// User registration
#[utoipa::path(
post,
path = "/api/auth/register",
tag = "Authentication",
request_body = RegisterUserSchema,
responses(
(status = 201, description = "Registration successful", body = UserResponse),
(status = 409, description = "Email already exists", body = ErrorResponse),
(status = 500, description = "Server error", body = ErrorResponse)
)
)]
pub async fn register_user_handler(
State(data): State<AppState>,
Json(body): Json<RegisterUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Implementation...
}
Protected Endpoint (Requires Authentication)
/// Get current user information
/// Requires JWT Bearer Token or Cookie authentication
#[utoipa::path(
get,
path = "/api/users/me",
tag = "Users",
security(
("bearerAuth" = []), // Method 1: JWT Token
("cookieAuth" = []) // Method 2: Cookie
),
responses(
(status = 200, description = "Success", body = UserResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse)
)
)]
pub async fn get_me_handler(
Extension(user): Extension<UserModel>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Implementation...
}
Path Parameters
#[utoipa::path(
delete,
path = "/api/keys/{key_id}",
tag = "API Keys",
params(
("key_id" = String, Path, description = "ID of the API Key")
),
security(
("bearerAuth" = [])
),
responses(
(status = 200, description = "Deletion successful"),
(status = 404, description = "Key not found")
)
)]
pub async fn delete_api_key_handler(
Path(key_id): Path<String>,
) -> impl IntoResponse {
// Implementation...
}
Query Parameters
#[utoipa::path(
get,
path = "/api/users",
tag = "Users",
params(
("page" = Option<i32>, Query, description = "Page number"),
("limit" = Option<i32>, Query, description = "Items per page")
),
responses(
(status = 200, description = "Success")
)
)]
pub async fn list_users(
Query(params): Query<ListParams>,
) -> impl IntoResponse {
// Implementation...
}
Defining Data Models
Basic Usage
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
#[schema(example = "123e4567-e89b-12d3-a456-426614174000")]
pub id: String,
#[schema(example = "john@example.com")]
pub email: String,
#[schema(example = "John Doe")]
pub name: String,
}
Advanced Field-Level Annotations
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegisterUserSchema {
/// Email address (must be unique)
#[schema(example = "user@example.com", max_length = 100)]
pub email: String,
/// Password (minimum 8 characters)
#[schema(example = "password123", min_length = 8)]
pub password: String,
/// User's full name
#[schema(example = "张三")]
pub name: String,
/// Whether user is an admin
#[schema(example = false)]
pub is_admin: bool,
}
Nested Structures
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginResponse {
pub data: LoginData,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginData {
#[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")]
pub token: String,
pub user: FilteredUser,
}
DateTime Handling (Important!)
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
pub id: String,
// ❌ This might not work in utoipa 4.x
// pub created_at: DateTime<Utc>,
// ✅ Correct: Use value_type override
#[schema(value_type = String, format = "date-time")]
pub created_at: DateTime<Utc>,
}
Or use custom formatting:
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiKeyInfo {
pub id: String,
pub name: String,
/// Creation time (ISO 8601 format)
#[schema(
value_type = String,
example = "2024-01-15T10:30:00Z"
)]
pub created_at: DateTimeWithTimeZone,
}
Generating OpenAPI Documentation
Complete ApiDoc Definition
use acrospire_auth::{
docs::JwtSecurityAddon,
docs::schemas::*,
handler::*,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
// 1. Register all API paths
paths(
health_checker_handler,
register_user_handler,
login_user_handler,
logout_handler,
get_me_handler,
refresh_token_handler,
create_api_key_handler,
list_api_keys_handler,
validate_api_key_handler,
delete_api_key_handler,
protected_data_handler
),
// 2. Register all data models
components(
schemas(
HealthCheckResponse,
RegisterUserSchema,
LoginUserSchema,
UserResponse,
FilteredUser,
LoginResponse,
LoginData,
LogoutResponse,
RefreshTokenRequest,
TokenPairResponse,
TokenPairData,
CreateApiKeySchema,
CreateApiKeyResponse,
CreateApiKeyData,
ApiKeyListResponse,
ApiKeyListData,
ApiKeyInfo,
ValidateApiKeySchema,
ValidateApiKeyResponse,
ValidateApiKeyData,
DeleteApiKeyResponse,
ErrorResponse,
)
),
// 3. Define tag groups
tags(
(name = "System", description = "System health check and similar endpoints"),
(name = "Authentication", description = "User registration, login, logout endpoints"),
(name = "Users", description = "User information management endpoints"),
(name = "API Keys", description = "API Key management endpoints"),
(name = "Protected", description = "Protected endpoints requiring API Key authentication")
),
// 4. API basic information
info(
title = "API Key Store Auth API",
version = "1.0.0",
description = "Authentication service API documentation based on JWT and API Key"
),
// 5. Add security schemes (critical!)
modifiers(&JwtSecurityAddon)
)]
struct ApiDoc;
// Generate OpenAPI documentation
let openapi = ApiDoc::openapi();
Integrating Swagger UI
Method: Integration in main.rs (Recommended)
use utoipa_swagger_ui::SwaggerUi;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_state = AppState::new().await?;
// Generate OpenAPI documentation
#[derive(OpenApi)]
#[openapi(
paths(...),
components(schemas(...)),
modifiers(&JwtSecurityAddon)
)]
struct ApiDoc;
let openapi = ApiDoc::openapi();
// Create router
let app = create_router()
.merge(
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", openapi)
.config(
utoipa_swagger_ui::Config::default()
.try_it_out_enabled(true) // Enable "Try it out"
.deep_linking(true) // Enable deep linking
.display_request_duration(true) // Show request duration
)
);
// Start server
let listener = tokio::net::TcpListener::bind("0.0.0.0:3081").await?;
axum::serve(listener, app).await?;
Ok(())
}
Swagger UI Configuration Options
SwaggerUi::new("/swagger-ui") // UI mount path
.url("/api-docs/openapi.json", openapi) // OpenAPI JSON path
.config(
utoipa_swagger_ui::Config::default()
// Configuration options
.try_it_out_enabled(true) // Enable testing functionality
.deep_linking(true) // Deep link to specific API
.display_request_duration(true) // Show request duration
.default_models_expand_depth(1) // Default model expansion depth
.persist_authorization(true) // Persist authentication info
.filter(true) // Enable search filtering
)
Access Addresses
After starting the server:
- Swagger UI: http://localhost:3081/swagger-ui
- OpenAPI JSON: http://localhost:3081/api-docs/openapi.json
Complete Workflow Example
1. Define Data Models
// src/docs/schemas.rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginUserSchema {
#[schema(example = "user@example.com")]
pub email: String,
#[schema(example = "password123")]
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginResponse {
pub data: LoginData,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginData {
#[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")]
pub token: String,
pub user: FilteredUser,
}
2. Define Security Schemes
// src/docs/security.rs
use utoipa::{Modify, OpenApi};
use utoipa::openapi::security::{SecurityScheme, HttpAuthScheme, Http};
pub struct JwtSecurityAddon;
impl Modify for JwtSecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer))
);
}
}
}
3. Implement API Handlers
// src/handler.rs
use crate::docs::schemas::*;
use utoipa::path;
#[utoipa::path(
post,
path = "/api/auth/login",
tag = "Authentication",
request_body = LoginUserSchema,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 401, description = "Authentication failed", body = ErrorResponse)
)
)]
pub async fn login_user_handler(
State(data): State<AppState>,
Json(body): Json<LoginUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Implement login logic
}
4. Generate OpenAPI Documentation
// src/main.rs
use acrospire_auth::{
docs::JwtSecurityAddon,
docs::schemas::*,
handler::*,
};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
paths(
health_checker_handler,
register_user_handler,
login_user_handler,
),
components(
schemas(
HealthCheckResponse,
LoginUserSchema,
LoginResponse,
)
),
tags(
(name = "System", description = "System health check endpoints"),
(name = "Authentication", description = "User registration and login endpoints"),
),
modifiers(&JwtSecurityAddon),
info(
title = "My API",
version = "1.0.0"
)
)]
struct ApiDoc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let openapi = ApiDoc::openapi();
let app = Router::new()
// ... add routes
.merge(SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", openapi));
// Start server...
}
5. Using Swagger UI
- Visit http://localhost:3081/swagger-ui
- Click 🔓 "Authorize" button
- Select authentication method (bearerAuth/cookieAuth/apiKeyAuth)
- Enter authentication credentials
- Click "Authorize" → "Close"
- Use "Try it out" to test APIs
Common Issues and Solutions
Issue 1: Authorize Button Not Showing
Cause:
- No security schemes added via
modifiers(&SecurityAddon) - Incorrect
SecurityAddonmodifymethod implementation SecurityAddonnot referenced inApiDoc
Solution:
// Ensure Modify trait is correctly implemented
impl Modify for JwtSecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(...);
}
}
}
// Use in ApiDoc
#[openapi(modifiers(&JwtSecurityAddon))]
struct ApiDoc;
Issue 2: Empty Paths Field
Cause:
- Handlers not listed in
ApiDoc'spaths(...) - Handler functions not marked with
#[utoipa::path] - Incorrect path references (function name mismatch)
Solution:
#[openapi(
paths(
handler::function1, // ✅ Correct: List all handlers
handler::function2,
)
)]
struct ApiDoc;
Issue 3: DateTime Type Not Supported
Problem:
#[derive(ToSchema)]
struct MyStruct {
pub created_at: DateTime<Utc>, // ❌ Might error
}
Solution:
#[derive(ToSchema)]
struct MyStruct {
#[schema(value_type = String, format = "date-time")]
pub created_at: DateTime<Utc>, // ✅ Use String type
}
Issue 4: Route Conflict Overlapping method route
Error Message:
Overlapping method route. Handler for GET /api-docs/openapi.json already exists
Cause:
Added SwaggerUi in multiple places, causing duplicate routes
Solution:
Only add Swagger UI in one place (recommend main.rs)
// ❌ Wrong: Added in both route.rs and main.rs
// route.rs
let app = Router::new().merge(swagger_ui)
// main.rs
let app = create_router().merge(swagger_ui) // Conflict!
// ✅ Correct: Only in main.rs
// route.rs - Don't add swagger_ui
// main.rs - Add it here
let app = create_router().merge(swagger_ui)
Issue 5: Supporting Multiple Authentication Methods
// Declare multiple authentication methods in handler
#[utoipa::path(
get,
path = "/api/users/me",
security(
("bearerAuth" = []), // Method 1
("cookieAuth" = []) // Method 2
)
)]
pub async fn get_me() {
// Implementation...
}
Users can choose any authentication method in Swagger UI.
Best Practices
1. Organize Code in Layers
docs/
├── mod.rs // Export interfaces
├── security.rs // Security schemes
├── schemas.rs // Data models
└── openapi.rs // Helper functions
2. Use Full Type Paths
#[openapi(
paths(
acrospire_auth::handler::login_handler, // ✅ Full path
// login_handler, // ❌ Errors if not in scope
)
)]
3. Add Examples for All Schemas
#[derive(ToSchema)]
pub struct CreateUserSchema {
#[schema(example = "user@example.com")]
pub email: String,
#[schema(example = "StrongPass123!")]
pub password: String,
}
4. Use Descriptive Text
#[utoipa::path(
post,
path = "/api/auth/login",
tag = "Authentication",
summary = "User login", // ← Add summary
description = "Login to the system using email and password, returns JWT Token on success", // ← Detailed description
responses(...)
)]
5. Standardize Error Response Format
#[derive(ToSchema)]
pub struct ErrorResponse {
pub status: String,
#[schema(example = "Operation failed")]
pub message: String,
}
// Reuse in all handlers
#[utoipa::path(
responses(
(status = 200, description = "Success"),
(status = 400, description = "Parameter error", body = ErrorResponse), // Reuse
(status = 500, description = "Server error", body = ErrorResponse), // Reuse
)
)]
Testing APIs with Swagger UI
- Login to Get Token:
POST /api/auth/login Body: { "email": "user@example.com", "password": "password123" } - Configure Authentication:
- Click "Authorize" button
- Select
bearerAuth - Enter returned Token
- Click "Authorize"
- Test Protected Endpoints:
- Click on protected endpoint
- Click "Try it out"
- Click "Execute"
- Swagger UI automatically adds Authorization header
Conclusion
Integrating utoipa with Axum gives you a powerful, type-safe API documentation system. The key steps are:
- Configure Dependencies - Correct
utoipaandutoipa-swagger-uiversions - Create Modify Implementation - Implement
JwtSecurityAddoninsecurity.rs - Mark Handlers - Use
#[utoipa::path(...)]andsecurity() - Mark Schemas - Use
#[derive(ToSchema)] - Define ApiDoc - Register paths, schemas, tags, and modifiers
- Integrate Swagger UI - Mount documentation using
SwaggerUi::new()
Following this guide, you'll have beautiful, interactive API documentation with full authentication support in no time. Happy coding!