axum-impl-openapi
Overview
Axum produces no API documentation of its own. The idiomatic solution is the
utoipa family of crates, which generates an OpenAPI 3 specification from Rust
annotations at compile time. Because the spec is derived from the same types
and handlers the server runs, it cannot silently drift from the code.
Three crates, each versioned independently:
utoipa(5.5.0): the derive and attribute macros that annotate types and handlers.utoipa-swagger-ui(9.0.2): bundles and serves the interactive Swagger UI.utoipa-axum(0.2.0): suppliesOpenApiRouter, which registers a handler ONCE for both routing and the spec, removing the last drift risk.
These three version numbers are NOT synchronised. ALWAYS pin all three
explicitly. The utoipa 4.x to 5.x bump changed macro behaviour; all code
here is valid for the 5.x line. Because utoipa-axum is still pre-1.0, treat
its OpenApiRouter surface as version-sensitive: a future 0.x release may
rename routes!, split_for_parts, or the constructors.
Quick Reference
Cargo dependencies (pin all three; versions verified 2026-05-20):
# version-sensitive: these three crates version independently
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9", features = ["axum"] }
The utoipa macro surface:
| Macro | Kind | Applies to | Generates |
|---|---|---|---|
ToSchema |
derive | a type | a reusable OpenAPI schema component |
#[utoipa::path(...)] |
attribute | a handler fn |
one OpenAPI operation |
OpenApi |
derive | a marker struct | the root OpenAPI document |
IntoParams |
derive | a query/path struct | grouped parameter definitions |
IntoResponses |
derive | a response enum/struct | grouped response definitions |
ToResponse |
derive | a type | a reusable response component |
Core rules:
- ALWAYS pin
utoipa,utoipa-axum, andutoipa-swagger-uito explicit, independent versions. NEVER assume their version numbers match. - ALWAYS write
#[utoipa::path]patharguments with the curly-brace{param}syntax. The macro requires it on BOTH Axum 0.7 and 0.8. - ALWAYS put the HTTP operation (
get,post, ...) as the FIRST argument of#[utoipa::path]. - ALWAYS prefer
utoipa-axum'sOpenApiRouterfor a new service: it registers each handler once and cannot drift. - ALWAYS enable the
axumfeature onutoipa-swagger-ui; without itSwaggerUicannot become an Axum router. - NEVER hand-write or hand-edit the OpenAPI JSON. The whole point is that the spec is generated.
Decision Trees
Which integration approach to use
Building the OpenAPI integration for an Axum service:
new service, want routing and spec from one source of truth
-> utoipa-axum OpenApiRouter + routes!() (register a handler once)
existing Router, adding docs incrementally, cannot restructure routing
-> plain utoipa: #[derive(OpenApi)] with #[openapi(paths(...))]
(register each handler twice, once routed, once in paths(...))
need only the spec file, no interactive UI
-> utoipa alone; serialise with ApiDoc::openapi().to_pretty_json()
Plain utoipa requires registering every handler twice: once on the Axum
Router and once inside #[openapi(paths(...))]. Forgetting one half means a
route exists but is undocumented, or a documented operation has no route.
OpenApiRouter derives both from a single .routes(routes!(...)) call, so the
two cannot disagree. ALWAYS pick OpenApiRouter for a new service.
The {param} versus :id path-syntax decision
Writing the path string for a documented route:
the #[utoipa::path] annotation -> ALWAYS {param} (e.g. "/pets/{id}")
the Axum route string on 0.8 -> {param} -> identical to utoipa
the Axum route string on 0.7 -> :param -> deliberately differs
#[utoipa::path] requires the OpenAPI brace form {id} on every Axum version.
Axum 0.8's native route syntax is also {id}, so on 0.8 the route string and
the utoipa::path annotation are character-for-character identical. On Axum
0.7 the route uses :id while the annotation still uses {id}; the two
strings differ on purpose. OpenApiRouter with routes!() removes the issue
entirely: it derives the route from the annotation, so there is one string.
Patterns
Pattern: a schema with #[derive(ToSchema)]
#[derive(ToSchema)] turns a type into a reusable OpenAPI schema component.
Doc comments become the OpenAPI description. Field-level #[schema(...)]
attributes refine the generated schema.
// utoipa 5.x
use utoipa::ToSchema;
#[derive(ToSchema, serde::Serialize)]
struct Pet {
/// Unique database id of the pet.
id: u64,
name: String,
#[schema(maximum = 30, minimum = 0)]
age: Option<i32>,
}
When both a serde attribute and a #[schema] equivalent are present on a
field, the serde attribute takes precedence. Every generic type parameter must
itself implement ToSchema.
Pattern: documenting a handler with #[utoipa::path]
#[utoipa::path(...)] documents one endpoint. The HTTP operation MUST be the
first argument. The path uses the {param} brace syntax.
// utoipa 5.x - the {param} path syntax is required on Axum 0.7 AND 0.8
#[utoipa::path(
get,
path = "/pets/{id}",
responses(
(status = 200, description = "Pet found successfully", body = Pet),
(status = NOT_FOUND, description = "Pet was not found")
),
params(
("id" = u64, Path, description = "Pet database id")
)
)]
async fn get_pet_by_id(/* extractors */) { /* ... */ }
responses(...) entries are (status = ..., description = "...", body = ...)
tuples; body is optional. params(...) accepts the tuple form shown or an
IntoParams struct.
Pattern: assembling the root document with #[derive(OpenApi)]
#[derive(OpenApi)] builds the root OpenAPI document on a marker struct.
paths(...) registers annotated handlers; components(schemas(...)) registers
ToSchema types. The derive implements the OpenApi trait, whose openapi()
function returns the utoipa::openapi::OpenApi value.
// utoipa 5.x
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_pet_by_id),
components(schemas(Pet))
)]
struct ApiDoc;
// ApiDoc::openapi() yields the spec; .to_pretty_json() serialises it
println!("{}", ApiDoc::openapi().to_pretty_json().unwrap());
This is the plain-utoipa approach: get_pet_by_id must ALSO be registered on
the Axum Router separately. The next pattern removes that duplication.
Pattern: OpenApiRouter registers a handler once
utoipa-axum's OpenApiRouter collects handlers through the routes!()
macro, deriving the HTTP method and path from each #[utoipa::path]
annotation. .split_for_parts() consumes the router and returns the runnable
axum::Router plus the finished OpenApi value.
// utoipa-axum 0.2.x - version-sensitive API; pin utoipa-axum = "0.2"
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
let (router, api): (axum::Router, utoipa::openapi::OpenApi) =
OpenApiRouter::new()
.routes(routes!(get_pet_by_id))
.split_for_parts();
OpenApiRouter::with_openapi(openapi) seeds the router with an existing
OpenApi value, so top-level info, tags, and servers from a
#[derive(OpenApi)] marker struct carry through. .nest(path, router) and
.merge(router) compose OpenApiRouters exactly like the Axum Router
methods while keeping the spec consistent.
Pattern: serving the Swagger UI
utoipa-swagger-ui's SwaggerUi serves the interactive UI and the raw spec
JSON. With the axum feature it converts into an Axum router and attaches via
Router::merge.
// utoipa-swagger-ui 9.x - the `axum` feature is mandatory for merge
use utoipa_swagger_ui::SwaggerUi;
let app = Router::new()
.route("/pets/{id}", get(get_pet_by_id)) // axum 0.8 route string
.merge(
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi()),
);
SwaggerUi::new("/swagger-ui") mounts the UI; .url(json_path, spec)
registers the spec at a JSON endpoint. On Axum 0.7 the route string is
"/pets/:id" while the utoipa::path annotation stays "/pets/{id}".
Pattern: the full three-crate integration
The idiomatic setup builds an OpenApiRouter, splits it, and feeds the
produced spec into SwaggerUi. Routing, schemas, and the UI all derive from
one registration.
// utoipa 5.x + utoipa-axum 0.2.x + utoipa-swagger-ui 9.x
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(components(schemas(Pet)))]
struct ApiDoc;
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(get_pet_by_id))
.split_for_parts();
let app = router.merge(
SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api),
);
get_pet_by_id is registered exactly once, in routes!(). Its route and its
spec operation are both derived from the single #[utoipa::path] annotation,
so they cannot drift.
Reference Links
references/methods.md: the fullutoipamacro surface, every verified#[utoipa::path]and#[schema]argument, theOpenApitrait, theOpenApiRoutermethod list with signatures,SwaggerUi, and the feature flags of all three crates.references/examples.md: a completeCargo.toml, schema and handler annotations, the plain-utoipaassembly, theOpenApiRouterfull integration,IntoParamsfor query structs, and serving the raw spec.references/anti-patterns.md: real mistakes with root-cause analysis, including hand-editing the spec, the route missing frompaths(...), the:idversus{id}mismatch, mismatched crate versions, the missingaxumfeature, and the operation argument placed afterpath.
Related skills: axum-core-router ({id} versus :id route syntax across
versions, .nest and .merge), axum-syntax-handlers (the handler signatures
that #[utoipa::path] annotates), axum-core-version-migration (the 0.7-to-0.8
route-syntax change).