utoipa generates OpenAPI 3.1 specifications at compile time using Rust macros. It integrates perfectly with Axum, turning documented Rust structs and functions into fully compliant OpenAPI JSON.
Dependency Setup
[dependencies]
utoipa = { version = "5.4", features = ["axum_extras", "openapi_extensions"] }
moka = { version = "0.12", features = ["future"] }
serde_json = "1"
Core Patterns at a Glance
1. Documenting Types (ToSchema)
Use #[derive(ToSchema)] on structs and enums. Include examples for better developer experience.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TaskResult {
/// The unique task ID (ULID)
#[schema(example = "01HT7M5A20X8V2Z1N7G3P4Y9BQ")]
pub tid: String,
/// Execution status
#[schema(example = "done")]
pub status: String,
}
2. Documenting Routes (#[utoipa::path])
Annotate Axum handlers with #[utoipa::path]. Specify parameters, request bodies, and responses.
use axum::{extract::Path, Json, http::StatusCode};
#[utoipa::path(
get,
path = "/v1/tasks/{kind}/{tid}",
responses(
(status = 200, description = "Task found", body = TaskResult),
(status = 404, description = "Task not found")
),
params(
("kind" = String, Path, description = "Task kind slug"),
("tid" = String, Path, description = "Task ULID")
),
security(
("AltchaAuth" = [])
)
)]
pub async fn get_task(
Path((_kind, _tid)): Path<(String, String)>
) -> Result<Json<TaskResult>, StatusCode> {
Ok(Json(TaskResult { tid: "01HT...".into(), status: "done".into() }))
}
3. Assembling and Caching the Spec (Axum + Moka)
In high-performance orchestrators, the OpenAPI JSON should be generated once, cached in memory using moka, and served via Axum.
use axum::{routing::get, Router, Json, extract::State};
use moka::future::Cache;
use std::time::Duration;
use utoipa::OpenApi;
use serde_json::Value;
#[derive(OpenApi)]
#[openapi(
paths(get_task),
components(schemas(TaskResult)),
info(title = "Platform API", version = "1.0.0"),
tags((name = "tasks", description = "Task execution API"))
)]
struct ApiDoc;
#[derive(Clone)]
struct AppState {
// Cache the JSON Value, not the string, so Axum sets Content-Type correctly
openapi_cache: Cache<String, Value>,
}
pub fn router() -> Router {
let cache = Cache::builder()
.time_to_live(Duration::from_secs(3600)) // 1 hour TTL
.build();
Router::new()
.route("/v1/openapi.json", get(serve_openapi))
.with_state(AppState { openapi_cache: cache })
}
async fn serve_openapi(State(state): State<AppState>) -> Json<Value> {
let spec = state.openapi_cache.get_with("openapi", async {
// Generate the spec on cache miss
let doc = ApiDoc::openapi();
serde_json::to_value(&doc).unwrap()
}).await;
Json(spec)
}