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

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 with Query or Path as individual parameters.

  • #[utoipa::path(...)] on a handler documents its method, path, parameters, request body, responses and security. With the axum_extras feature, Path, Query and Json arguments are detected automatically.

  • #[derive(OpenApi)] on an empty struct assembles the document: info, tags, components, a modifiers list 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();
}
$ 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"
  }
}

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 one MethodRouter), which is why get_book has its own call.

  • The path in #[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 normal axum::Router, so state, layers and the rest of the application are unchanged.

  • The vendored feature 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.

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-generator or openapi-typescript, and Rust clients with progenitor;

  • check in the generated openapi.json and fail CI when it changes unexpectedly (insta snapshot of ApiDoc::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.