OpenAPI with utoipa
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against utoipa 6.0.0, utoipa-axum 0.3.0 and utoipa-swagger-ui 10.0.1 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. axum does not generate API documentation by itself — routes are ordinary values, not annotated functions. utoipa fills the gap with derive macros and attributes that produce an OpenAPI document at compile time; FastAPI and NestJS users will recognise the approach (see the FastAPI OpenAPI page for comparison).
[dependencies]
utoipa = { version = "6.0.0", features = ["axum_extras", "uuid"] }
utoipa-axum = "0.3.0"
utoipa-swagger-ui = { version = "10.0.1", features = ["axum", "vendored"] } # vendored: no download at build time
Source: utoipa: crate features and utoipa-swagger-ui 10.0.1
Documenting handlers and types
-
#[derive(ToSchema)]produces a JSON Schema for a type, honouring serde attributes (rename_all,skip,default,flatten), doc comments (as descriptions) and#[schema(example = ...)]. -
#[derive(IntoParams)]documents a struct used withQueryorPathas individual parameters. -
#[utoipa::path(...)]on a handler documents its method, path, parameters, request body, responses and security. With theaxum_extrasfeature,Path,QueryandJsonarguments are detected automatically. -
#[derive(OpenApi)]on an empty struct assembles the document: info, tags, components, amodifierslist for programmatic changes such as security schemes.
Keeping routes and docs in sync with utoipa-axum
The classic approach lists every handler twice — once in the router, once in #[openapi(paths(...))] — and the two
drift. utoipa_axum::router::OpenApiRouter registers both at once: routes!(handler_a, handler_b) reads each handler’s
#[utoipa::path] and adds the route and its documentation. split_for_parts() then returns the plain axum Router
and the OpenApi document:
use axum::{Json, extract::{Path, Query}, http::StatusCode};
use serde::{Deserialize, Serialize};
use utoipa::{
IntoParams, Modify, OpenApi, ToSchema,
openapi::{
OpenApiVersion,
security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
},
};
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
/// A book in the catalogue.
#[derive(Serialize, Deserialize, ToSchema, Clone)]
#[serde(rename_all = "camelCase")]
struct Book {
/// ISBN-13 without hyphens.
#[schema(example = "9780441013593")]
isbn: String,
#[schema(example = "Dune")]
title: String,
#[schema(minimum = 1450, maximum = 2100)]
year: u16,
}
/// Problem Details (RFC 9457).
#[derive(Serialize, ToSchema)]
struct Problem {
title: String,
status: u16,
detail: Option<String>,
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
struct ListParams {
/// Case-insensitive title search.
q: Option<String>,
/// Page size (1-100).
limit: Option<u32>,
}
/// List books
#[utoipa::path(
get,
path = "/books",
tag = "books",
params(ListParams),
responses((status = OK, description = "Matching books", body = [Book]))
)]
async fn list_books(Query(_params): Query<ListParams>) -> Json<Vec<Book>> {
Json(vec![Book { isbn: "9780441013593".into(), title: "Dune".into(), year: 1965 }])
}
/// Get one book
#[utoipa::path(
get,
path = "/books/{isbn}",
tag = "books",
params(("isbn" = String, Path, description = "ISBN-13")),
responses(
(status = OK, body = Book),
(status = NOT_FOUND, body = Problem, content_type = "application/problem+json")
)
)]
async fn get_book(Path(isbn): Path<String>) -> Result<Json<Book>, (StatusCode, Json<Problem>)> {
if isbn == "9780441013593" {
Ok(Json(Book { isbn, title: "Dune".into(), year: 1965 }))
} else {
Err((StatusCode::NOT_FOUND, Json(Problem { title: "Not Found".into(), status: 404, detail: None })))
}
}
/// Add a book (librarians only)
#[utoipa::path(
post,
path = "/books",
tag = "books",
request_body = Book,
responses((status = CREATED, body = Book), (status = UNAUTHORIZED)),
security(("bearer" = []))
)]
async fn create_book(Json(book): Json<Book>) -> (StatusCode, Json<Book>) {
(StatusCode::CREATED, Json(book))
}
struct BearerAuth;
impl Modify for BearerAuth {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()),
);
}
}
#[derive(OpenApi)]
#[openapi(
info(title = "Bookshelf API", version = "1.0.0", description = "The Bookshelf catalogue"),
tags((name = "books", description = "Books in the catalogue")),
modifiers(&BearerAuth)
)]
struct ApiDoc;
#[tokio::main]
async fn main() {
let (api_router, mut api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.nest("/api/v1", OpenApiRouter::new().routes(routes!(list_books, create_book)).routes(routes!(get_book)))
.split_for_parts();
api.openapi = OpenApiVersion::Version32; // utoipa 6 emits 3.1 by default; 3.2 is opt-in
let app = api_router.merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", api));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: utoipa-axum 0.3 and utoipa: #[utoipa::path]
$ curl -s localhost:3000/api-docs/openapi.json | jq '.openapi, (.paths | keys), .components.securitySchemes'
"3.2.0"
[
"/api/v1/books",
"/api/v1/books/{isbn}"
]
{
"bearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
Source: utoipa-axum 0.3 and utoipa: #[utoipa::path]
Opening http://localhost:3000/docs/ shows Swagger UI with "Try it out" for every operation.
-
A
routes!call may only group handlers for the same path (it builds oneMethodRouter), which is whyget_bookhas its own call. -
The
pathin#[utoipa::path]is relative to where the router is nested;nest("/api/v1", ...)prefixes both the axum route and the documented path. -
split_for_parts()returns a normalaxum::Router, so state, layers and the rest of the application are unchanged. -
The
vendoredfeature of utoipa-swagger-ui embeds the Swagger UI assets at build time; without it, the build script downloads them, which fails in offline or sandboxed CI.
OpenAPI 3.1 and 3.2
utoipa 6.0 (September 2026) generates OpenAPI 3.1 by default and can emit OpenAPI 3.2 by setting
OpenApiVersion::Version32 (or OpenApiBuilder::openapi(...)). 3.2 adds, among other things, the QUERY operation,
itemSchema for streamed payloads such as Server-Sent Events and JSON Lines, tag summary/parent/kind, and OAuth2
device authorisation. Keep 3.1 if your client generators or gateways do not understand 3.2 yet. utoipa 6 also raised
its MSRV to 1.88 and switched its YAML support to yaml_serde.
Source: utoipa CHANGELOG
Swagger UI, Scalar and Redoc
utoipa has sibling crates for the common documentation UIs: utoipa-swagger-ui, utoipa-redoc, utoipa-rapidoc and
utoipa-scalar. Each turns the OpenApi value into an axum router you merge into the application. In production,
decide whether the docs are public; if not, put them behind the admin authentication or serve only the JSON document.
aide: a code-first alternative
aide takes the opposite approach: instead of attributes, it infers the documentation from the handler’s extractor and
response types (through schemars), and you add descriptions with builder functions next to the route
(api_route("/books", get_with(list_books, |op| op.description("List books")))). It avoids repeating the path and
parameter types in attributes but couples the router more tightly to aide’s types. Both approaches produce standard
OpenAPI documents.
Generating clients and testing against the spec
The OpenAPI document is a contract. Common uses:
-
generate TypeScript or Kotlin clients with
openapi-generatororopenapi-typescript, and Rust clients withprogenitor; -
check in the generated
openapi.jsonand fail CI when it changes unexpectedly (instasnapshot ofApiDoc::openapi().to_pretty_json()), so breaking API changes are reviewed; -
run contract tests that validate real responses against the schema (see Testing).
Where the books are outdated
Yoon (Beginning Axum) documents its API with utoipa 5 and lists handlers in #[openapi(paths(...))]. utoipa 6 keeps
that API but adds OpenAPI 3.2 output, removes "expression ignore" in ToSchema/IntoParams, and requires Rust 1.88;
utoipa-axum's OpenApiRouter is the less error-prone way to register routes.