Making it Easy to Use OpenAPI in Your Rust Projects
Rust has stood out as a high-performance and secure language. Obviously, this is not limited to low-level development; thanks to its rich ecosystem of crates, Rust is also a great alternative for web development.
However, one problem remains: the productivity of the average developer in Rust is usually not as high as in other languages. Knowing this, the Rust community has been working hard to create tools that improve this experience. One of these tools is the Utoipa crate, which allows for the automatic generation of OpenAPI documentation for web APIs developed in Rust, with integrations for the main web servers of the language, such as Actix-web and Axum. Its operation is very direct and simple, reminiscent of the implementation of Swagger in other languages; you just need to annotate your endpoints with specific attributes, and the crate takes care of generating the necessary documentation.
Although simple in concept, in practice, using pure Utoipa can become a headache, especially in larger projects where the number of endpoints and data models grows rapidly. Maintaining all these annotations can become laborious and prone to errors. This is due to its standard operation, which depends on you manually defining the path for each handler in the struct that implements the OpenAPI trait, which is certainly laborious and repetitive. As an example, we have the following code snippet from a very small API I was recently working on:
#[derive(utoipa::OpenApi)] #[openapi( paths( crate::http::handlers::auth::login, crate::http::handlers::auth::register, crate::http::handlers::auth::refresh, crate::http::handlers::auth::logout, crate::http::handlers::auth::me, crate::http::handlers::auth::update_profile, crate::http::handlers::auth::change_password, crate::http::handlers::auth::delete_account, crate::http::handlers::user::get_user_by_id, crate::http::handlers::table::create_table, crate::http::handlers::table::get_tables, crate::http::handlers::table::get_table_details, crate::http::handlers::table::update_table, crate::http::handlers::table::delete_table, crate::http::handlers::session::create_session, crate::http::handlers::session::get_sessions, crate::http::handlers::session::update_session, crate::http::handlers::session::delete_session, crate::http::handlers::table_request::create_table_request, crate::http::handlers::table_request::get_sent_requests, crate::http::handlers::table_request::get_received_requests, crate::http::handlers::table_request::accept_request, crate::http::handlers::table_request::reject_request, crate::http::handlers::table_request::cancel_request, crate::http::handlers::game_system::create_game_system, crate::http::handlers::health::health_check ), modifiers(&SecurityAddon), tags( (name = "auth", description = "Authentication endpoints"), (name = "users", description = "User management endpoints"), (name = "tables", description = "RPG table management endpoints"), (name = "sessions", description = "Session management endpoints"), (name = "table-requests", description = "Table request management endpoints"), (name = "health", description = "Health check endpoints"), (name = "game_systems", description = "GameSystems endpoints") ), info( title = "JOS", description = "Join Our Session (JOS) - API for managing RPG tables and sessions", version = "1.0.0" ) )] pub struct ApiDoc;
Consider that for each endpoint listed there, there is a corresponding handler annotated with Utoipa attributes, which makes the code repetitive and difficult to maintain.
#[utoipa::path(
post,
path = "/v1/auth/login",
tag = "auth",
request_body = LoginRequest,
responses()
)]
#[axum::debug_handler]
async fn login(
State(auth_service): State<Arc<dyn IAuthService>>,
Json(login_payload): Json<LoginRequest>,
) -> Result<(StatusCode, Json<LoginResponse>), HttpError> {
todo!()
}
Initially, I believed there was no other way, even though I wanted to die inside every time I needed to add a new endpoint, until I decided to stop and look for a solution. Curiously, I noticed that most of the code that used Axum and Utoipa together ended up adopting a similar approach, where the handlers were organized into modules and sub-modules, reflecting the structure of the API. In other words, everyone ended up having the same problem of repetition and maintenance difficulty.
After much research, I finally came across the utoipa-axum crate, which offers a proposal for this solution. Yes, the name is self-explanatory. Yes, I should have read the documentation carefully and tried to understand Utoipa’s proposal before writing code like a maniac, but the curious thing is that apparently I was not the only one, so here we are. Since this crate fortunately exists, let’s understand a little about how it works. I will use Axum as an example, but from what I’ve seen, there is a similar crate for all other frameworks supported by Utoipa.
The operation of Axum, as you probably already know, is based on a few main components. One of the most used is the `Router`, which is responsible for managing the application’s routes, including middlewares, handlers, etc. A “Hello, World!” with Axum would look something like this:
use axum::{routing::get, Router, Server, serve};
use tokio::net::TcpListener;
async fn hello() -> &'static str {
"Hello, World!"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
serve(listener, app).await.unwrap();
}
Basically, we define a simple handler that returns “Hello, World!” and associate it with the root route “/” using the route method of the Router. We also need to specify the HTTP method that will be used, in this case get. Now, running this code, we will have a simple web server that responds with “Hello, World!” to GET requests on the root “/”.
lucasbrt@nixos /t/tes (master)> cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s Running `target/debug/tes`
And we can easily test this using curl:
lucasbrt@nixos ~> curl -X GET localhost:3000 Hello, World!⏎
Now let’s add Utoipa to this simple example. For this, we need to add the necessary dependencies to Cargo.toml:
cargo add utoipa
After that, let’s refactor the application to include OpenAPI documentation using Utoipa:
use axum::{Router, routing::get, serve};
use tokio::net::TcpListener;
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(paths(crate::endpoints::hello))]
struct ApiDoc;
mod endpoints {
#[utoipa::path(get, path = "/")]
pub async fn hello() -> &'static str {
"Hello, World!"
}
}
#[tokio::main]
async fn main() {
let doc = ApiDoc::openapi();
let app = Router::new().route("/", get(endpoints::hello));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("OpenAPI spec: {:?}", doc.paths.paths.keys());
serve(listener, app).await.unwrap();
}
By running this code, we will have the same functionality as before, but now with the OpenAPI documentation automatically generated by Utoipa.
lucasbrt@nixos /t/tes (master)> cargo run Compiling tes v0.1.0 (/tmp/tes) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.70s Running `target/debug/tes` OpenAPI spec: ["/"]
Everything works as expected, but as mentioned earlier, we defined the route of an endpoint manually in both the Router and the ApiDoc struct, which does not solve our problem of repetition and maintenance difficulty. Automatically Detecting Routes with utoipa-axum To avoid this repetition, we can use the utoipa-axum crate, which offers a way to automatically detect the routes defined in the Axum Router and generate the corresponding OpenAPI documentation. First, let’s add the utoipa-axum dependency to our Cargo.toml:
cargo add utoipa-axum
The main change we will make is to abandon the use of the standard Axum Router and use the OpenApiRouter. In addition, instead of assembling the documentation separately from the route assembly itself, the documentation itself will now define how the routes will be assembled.
use axum::{Router, serve};
use tokio::net::TcpListener;
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
#[derive(OpenApi)]
#[openapi(paths(crate::endpoints::hello))]
struct ApiDoc;
mod endpoints {
#[utoipa::path(get, path = "/")]
pub async fn hello() -> &'static str {
"Hello, World!"
}
}
#[tokio::main]
async fn main() {
let (app, doc): (Router, utoipa::openapi::OpenApi) = OpenApiRouter::new()
.routes(routes!(endpoints::hello))
.split_for_parts();
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("OpenAPI spec: {:?}", doc.paths.paths.keys());
serve(listener, app).await.unwrap();
}
Notice that we are now using OpenApiRouter to create the router, and instead of defining the routes manually, we use the routes method, passing a list of Utoipa-annotated handlers generated by the routes! macro. The split_for_parts method returns both the Router and the automatically generated OpenAPI documentation. Running this code, we will have the same functionality as before, but now without the repetition in the route definition:
lucasbrt@nixos /t/tes (master)> cargo run warning: struct `ApiDoc` is never constructed → src/main.rs:8:8 | 8 | struct ApiDoc; | ^^^^^^ | = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default warning: `tes` (bin "tes") generated 1 warning Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s Running `target/debug/tes` OpenAPI spec: ["/"]
As you can see, the warning advises that we are not using the `ApiDoc` struct, but this is expected, as its only function is to serve as a container for the Utoipa attributes. This is because OpenApiRouter already creates its own struct that implements the OpenAPI trait internally. If you want to document specific characteristics of the API, such as title, description, etc., you can do so by defining them normally in your ApiDoc and instantiating OpenApiRouter through with_openapi, as in the example below:
#[derive(OpenApi)]
#[openapi(info(title = "My API", version = "1.0", description = "An example API"))]
struct ApiDoc;
…
let (app, doc): (Router, utoipa::openapi::OpenApi) =
OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(endpoints::hello))
.split_for_parts();
…
Automatically Generating the Swagger UI As an extra, let’s add the Swagger UI to view the generated documentation interactively. For this, we need to add another dependency to our Cargo.toml:
cargo add utoipa-swagger-ui --features axum
Now, we can integrate the Swagger UI into our Axum server. To do this, we simply add a new route that serves the Swagger UI, pointing to the generated OpenAPI documentation:
use axum::{Router, serve};
use tokio::net::TcpListener;
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(info(title = "My API", version = "1.0", description = "An example API"))]
struct ApiDoc;
mod endpoints {
#[utoipa::path(get, path = "/")]
pub async fn hello() -> &'static str {
"Hello, World!"
}
}
#[tokio::main]
async fn main() {
let (app, doc): (Router, utoipa::openapi::OpenApi) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(endpoints::hello))
.split_for_parts();
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
let app = app.merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", doc));
serve(listener, app).await.unwrap();
}
Now, when you run the server and access http://localhost:3000/docs, you will see the Swagger UI interface displaying your API’s documentation interactively.
Conclusion
As you can see, the Rust ecosystem is rapidly evolving to offer tools that improve developer productivity. The combination of Axum with Utoipa and `utoipa-axum` is a clear example of this, allowing you to create robust web APIs with automatic documentation simply and efficiently. I hope this article has helped you understand how to facilitate the use of Utoipa in your Rust projects. If you want to take a closer look at more examples, you can find more examples of this integration in my personal repository
Read the full article here: https://medium.com/@Lucas-BRT/making-it-easy-to-use-utoipa-in-your-rust-projects-a5fdfd808b6f