favicon here hometagsblogmicrobio cvtech cvgpg keys

Axum with utoipa

Soc Virnyl Estela | 2026-01-21 | reading time: ~5min

I have been looking for existing solutions for a Rust equivalent of FastAPI — building APIs with the type safety and the convenience of auto-generated API docs with OpenAPI.

And after searching all over the internet, I have found utoipa. Currently, I am most familiar with the Axum web framework when building APIs in Rust.

So in this blog post, I'll show you how to create a simple API with axum and utoipa.

Preparing your project§

Run the following commands to initialise a new Rust project

cargo new axum-api
cd axum-api

Let's add the dependencies

cargo add tokio -F full
cargo add serde -F derive
cargo add serde_json
cargo add axum
cargo add utoipa-swagger-ui -F axum
cargo add utoipa-axum
cargo add utoipa

What this project does§

This project will just do simple CRUD operations and have the following endpoints

  • POST /items/create
  • GET /items/{id}
  • DELETE /items/{id}

We can have an SQLite database or some kind of storage we can use to simulate CRUD. But I'll just be lazy and use a dashmap. Hence, adding a new dependency.

cargo add dashmap -F serde
cargo add rand  # To generate random ids

Creating our types§

Let's create our types. Here, we have Item and ItemBucket

use dashmap::DashMap;
use serde::Deserialize;
use serde::Serialize;

#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, Clone)]
struct Item {
    pub size: usize,
    pub name: String,
}

type ItemBucket = Arc<DashMap<u64, Item>>;

The ItemBucket uses Arc or atomic reference counted to allow sharing ownership of the data. The data is of type DashMap<u64, Item>> which is just a wrapper for HashMap<u64<Item>> as Dashmap<T> is a direct replacement of RwLock<HashMap<T>>. Thus, we can set a new type for shared "app state" or API state to "store" data.

#[derive(Default)]
struct AppState {
    bucket: ItemBucket,
}

Building our API endpoints§

Here are the following methods for our POST, GET, and DELETE endpoints

#[utoipa::path(post,path= "/create", responses((status = OK, body = Item)))]
async fn create_item(Extension(app_state): Extension<Arc<AppState>>) -> impl IntoResponse {
    let id: u64 = random();
    let name = generate_random_string(10);
    let size = name.capacity();
    let item = Item { size, name };
    app_state.bucket.insert(id, item.clone());
    Json((id, item))
}

#[utoipa::path(get, path="/{id}", responses((status=OK, body=(u64, Option<Item>))))]
async fn get_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.get(&id);
    let item = maybe_item.map(|v| v.clone());
    Json((id, item))
}

#[utoipa::path(delete, path="/{id}", responses((status=OK, body=(u64, Option<Item>))))]
async fn delete_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.remove(&id);
    let item = maybe_item.map(|(_, v)| v.clone());
    Json((id, item))
}

And here is the core logic of our main function

use axum::Extension;
use axum::Json;
use axum::Router;
use axum::extract::Path;
use axum::response::IntoResponse;
use rand::prelude::*;
use std::sync::Arc;
use utoipa::openapi::OpenApiBuilder;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;

use dashmap::DashMap;
use rand::random;
use serde::Deserialize;
use serde::Serialize;
use utoipa_swagger_ui::SwaggerUi;

/*
...our other code here
*/

#[tokio::main]
async fn main() {
    let app_state = Extension(Arc::new(AppState::default()));

    let (app, api) = OpenApiRouter::new()
        .routes(routes!(create_item))
        .routes(routes!(get_item))
        .routes(routes!(delete_item))
        .layer(app_state.clone())
        .split_for_parts();
    let open_api_builder = OpenApiBuilder::new().build().nest("/items", api);

    let url = "localhost:3000";
    let listener = tokio::net::TcpListener::bind(&url).await.unwrap();
    let swagger_docs = SwaggerUi::new("/docs").url("/api-docs/openapi.json", open_api_builder);
    let app = Router::new().nest("/items", app).merge(swagger_docs);
    axum::serve(listener, app).await.unwrap();
}

We use the following declaration

    let (app, api) = OpenApiRouter::new()
        .routes(routes!(create_item))
        .routes(routes!(get_item))
        .routes(routes!(delete_item))
        .layer(app_state.clone())
        .split_for_parts();

to create an axum router called app and OpenAPI information called api which is passed to OpenApiBuilder. We used .nest method on the OpenApiBuilder so our endpoints will start with the route path /items.

Honestly, my only issue currently with the SwaggerUi initialisation is the hard-coded /api-docs/openapi.json. It causes some path issues if the variable is merged incorrectly into the router e.g.

    let app = Router::new().nest("/items", app.merge(swagger_docs));

One would expect that /items/docs is the path to the OpenAPI documentation which is correct, but because of the .url method, you would also expect that it automatically points to /items/api-docs/openapi.json... which does not. Thus, the example gives confusion and only shows up if you edit the path by prepending /items/ in the search bar. For now, my only workaround and safer bet is to ensure that /docs should be at the top-level path of the URL so we merge it giving the complete URL to be localhost:3000/docs while also ensuring that our /api-docs are also at the top-level path next to the root /, hence, pointing to the correct openapi.json data.

Testing our API§

Here is the fullcode of the API (click to toggle dropdown)
use axum::Extension;
use axum::Json;
use axum::Router;
use axum::extract::Path;
use axum::response::IntoResponse;
use rand::prelude::*;
use std::sync::Arc;
use utoipa::openapi::OpenApiBuilder;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;

use dashmap::DashMap;
use rand::random;
use serde::Deserialize;
use serde::Serialize;
use utoipa_swagger_ui::SwaggerUi;

#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, Clone)]
struct Item {
    pub size: usize,
    pub name: String,
}

type ItemBucket = Arc<DashMap<u64, Item>>;

#[derive(Default)]
struct AppState {
    bucket: ItemBucket,
}

fn generate_random_string(length: usize) -> String {
    const CHARSET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    let mut rng = rand::rng();

    (0..length)
        .map(|_| {
            let idx = rng.random_range(0..CHARSET.len());
            CHARSET[idx] as char
        })
        .collect()
}

#[utoipa::path(post,path= "/create", responses((status = OK, body = (u64, Item))))]
async fn create_item(Extension(app_state): Extension<Arc<AppState>>) -> impl IntoResponse {
    let id: u64 = random();
    let name = generate_random_string(10);
    let size = name.capacity();
    let item = Item { size, name };
    app_state.bucket.insert(id, item.clone());
    Json((id, item))
}

#[utoipa::path(get, path="/{id}", responses((status=OK, body=(u64, Option<Item>))))]
async fn get_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.get(&id);
    let item = maybe_item.map(|v| v.clone());
    Json((id, item))
}

#[utoipa::path(delete, path="/{id}", responses((status=OK, body=(u64, Option<Item>))))]
async fn delete_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.remove(&id);
    let item = maybe_item.map(|(_, v)| v.clone());
    Json((id, item))
}

#[tokio::main]
async fn main() {
    let app_state = Extension(Arc::new(AppState::default()));

    let (app, api) = OpenApiRouter::new()
        .routes(routes!(create_item))
        .routes(routes!(get_item))
        .routes(routes!(delete_item))
        .layer(app_state.clone())
        .split_for_parts();
    let open_api_builder = OpenApiBuilder::new().build().nest("/items", api);

    let url = "localhost:3000";
    let listener = tokio::net::TcpListener::bind(&url).await.unwrap();
    let swagger_docs = SwaggerUi::new("/docs").url("/api-docs/openapi.json", open_api_builder);
    let app = Router::new().nest("/items", app).merge(swagger_docs);
    axum::serve(listener, app).await.unwrap();
}

Run the following command at the root of the project

cargo run

And open the webpage at localhost:3000/docs to see your API docs

image

You can start creating new items by interacting the POST /items/create section.

image

But there is a problem§

You'll notice by now that interacting with the GET /items/{id} with the ID presented by the response you got from POST /items/create gives you a similar response like below

[
  2852292982480790000,
  null
]

You can test this by checking that the IDs do not really match by modifying the create_item method into

#[utoipa::path(post,path= "/create", responses((status = OK, body = (u64, Item))))]
async fn create_item(Extension(app_state): Extension<Arc<AppState>>) -> impl IntoResponse {
    let id: u64 = random();
    println!("Creating item with ID: {}", id);
    let name = generate_random_string(10);
    let size = name.capacity();
    let item = Item { size, name };
    app_state.bucket.insert(id, item.clone());
    Json((id, item))
}

so you can get output like this

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.48s
     Running `target/debug/axum-api`
Creating item with ID: 4874131135699964599

The cause of this problem? Javascript.

How to handle large numbers§

Javascript cannot handle numbers that are larger than 9,007,199,254,740,991. To put that into perspective, let's say we set our path parameter as type Path<u64>. Surely enough, Rust can handle that large number but our Swagger UI cannot since it runs on Javascript via web browser.

You can test with the curl command and it should work just fine though on curl.

To fix this issue, you either change the integer type that can fit the integer limit of 9,007,199,254,740,991 from Javascript e.g. u8 or u32. Another nicer approach is to pass a custom JSON with the id as a string as part of our response which is the most common solution to this problem. Hence, each of our API endpoints should return numbers as a string if we want to preserve its size and length for conversion later in the Rust backend.

#[utoipa::path(post,path= "/create", responses((status = OK, body = (String, Item))))]
async fn create_item(Extension(app_state): Extension<Arc<AppState>>) -> impl IntoResponse {
    let id: u64 = random();
    println!("Creating item with ID: {}", id);
    let name = generate_random_string(10);
    let size = name.capacity();
    let item = Item { size, name };
    app_state.bucket.insert(id, item.clone());
    Json((id.to_string(), item))
}

#[utoipa::path(get, path="/{id}", responses((status=OK, body=(String, Option<Item>))))]
async fn get_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.get(&id);
    let item = maybe_item.map(|v| v.clone());
    Json((id.to_string(), item))
}

#[utoipa::path(delete, path="/{id}", responses((status=OK, body=(String, Option<Item>))))]
async fn delete_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.remove(&id);
    let item = maybe_item.map(|(_, v)| v.clone());
    Json((id.to_string(),item))
}

You can see here that we have adjusted by calling the .to_string method. You might want to add a method for Item to convert large numbers to strings e.g.

impl Item {
    fn to_javascript_compatible_values(&self) -> serde_json::Value {
        let size_str = self.size.to_string();
        let name = self.name.to_string();
        serde_json::json!(
            { "size": size_str,
               "name": name
            }
        )
    }
}

of which we can now use to further update the API endpoint functions

#[utoipa::path(post,path= "/create", responses((status = OK, body = (String, serde_json::Value))))]
async fn create_item(Extension(app_state): Extension<Arc<AppState>>) -> impl IntoResponse {
    let id: u64 = random();
    println!("Creating item with ID: {}", id);
    let name = generate_random_string(10);
    let size = name.capacity();
    let item = Item { size, name };
    app_state.bucket.insert(id, item.clone());
    Json((id.to_string(), item.to_javascript_compatible_values()))
}

#[utoipa::path(get, path="/{id}", responses((status=OK, body=(String, Option<serde_json::Value>))))]
async fn get_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.get(&id);
    let item = maybe_item.map(|v| v.to_javascript_compatible_values());
    Json((id.to_string(), item))
}

#[utoipa::path(delete, path="/{id}", responses((status=OK, body=(String, Option<serde_json::Value>))))]
async fn delete_item(
    Path(id): Path<u64>,
    Extension(app_state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    let maybe_item = app_state.bucket.remove(&id);
    let item = maybe_item.map(|(_, v)| v.to_javascript_compatible_values());
    Json((id.to_string(), item))
}

Conclusion§

You can see the full result in the following video below

The full repository can be found here.

Articles from blogs I follow around the net

GreyNoise Labs Weekly OAST (Well-known Out-of-band Interaction Domains) Report • Week Ending 2026-01-13

Overview GreyNoise sensors captured extensive scanning activity targeting web application vulnerabilities with Out-of-band Application Security Testing (OAST) callback domains embedded in exploit payloads. The activity spanned seven days with peak concent…

via GreyNoise LabsJanuary 24, 2026

The Birthday Paradox, simulated

I'm a fan of simulating counterintuitive statistics. I recently did this with the Monty Hall problem and I really enjoyed how it turned out. A similarly interesting statistical puzzle is the birthday paradox: you only need to get 23 people in a room a room…

via pcloadletterJanuary 23, 2026

Status update, January 2026

Hi! Last week I’ve released Goguma v0.9! This new version brings a lot of niceties, see the release notes for more details. New since last month are audio previews implemented by delthas, images for users, channels & networks, and usage hints when typing a…

via emersionJanuary 21, 2026

The Only Two Markup Languages

There are only two families of proper arbitrary markup languages: TeX and SGML I would normally link to official thing as reference but it's behind the "wonderful" ISO paywall: ISO 8879:1986.. By arbitrary, I mean the grammar specifically, and how it can …

via gingerBill - ArticlesJanuary 19, 2026

Software Assurance & That Warm and Fuzzy Feeling

If I were to recommend you use a piece of cryptography-relevant software that I created, how would you actually know if it was any good? Trust is, first and foremost, a social problem. If I told you a furry designed a core piece of Internet infrastructure,…

via Dhole MomentsJanuary 15, 2026

Redesigning my microkernel from the ground up

As you may recall, circa 2022-2023 I was working on a microkernel written in Hare named Helios. Helios was largely inspired by and modelled after the design of seL4 and was my first major foray into modern OS development that was serious enough to get to a…

via Drew DeVault's blogJanuary 12, 2026

I transformed my LEGO Gameboy into a working Gameboy!

Since I saw the Lego Gameboy set, I wanted to make it functional since I have good knowledge of programming and little knowledge of electronics. Also I've been a big fan of Gameboys since I was a kid, so I thought it would be a fun project to combine my…

via Christian Visintin BlogJanuary 06, 2026

Gbyte leaks gigabytes of data - #FuckStalkerware pt. 8

plus an MMO boosting service, fully remote Android spying and patented ToS violations

via maia blogJanuary 06, 2026

Whiplash and the ideas of success

Some ideas about success and analysis of some of the concepts of the film Whiplash

via Ishan WritesJanuary 04, 2026

reqwest v0.13 - rustls by default

To end out the year, here comes a new major release of reqwest, the opinionated higher-level HTTP client for Rust. We don’t really need major breaking versions to keep providing value. Improvements keep coming all the time. But we did need one to make one…

via seanmonstarDecember 30, 2025

Merry Christmas, Ya Filthy Animals (2025)

It’s my last day of writing for the year, so I’m going to try keep this one quick – it was knocked out over three hours, so I hope you can forgive me if it’s a bit clumsier than my usual writing. For some strange reason, one of the few clear memories I hav…

via LudicityDecember 27, 2025

Are people migrating away from GitHub?

I noticed some people migrating away from GitHub recently. I was curious to understand the rationale. Is it a blip or is it a sign of prolonged exodus?

via Rob O'Leary | BlogDecember 22, 2025

Yep, Passkeys Still Have Problems

It's now late into 2025, and just over a year since I wrote my last post on Passkeys. The prevailing dialogue that I see from thought leaders is "addressing common misconceptions" around Passkeys, the implication being that "you just don't understand it co…

via Firstyear's blog-a-logDecember 17, 2025

Theme selector

Two weeks ago I added dark mode to this website. It was late one night and I was revisiting an article and my eyes were tired, so that was that. It was based solely on system dark mode settings, and I started using some more nice, modern CSS features like …

via macwright.comDecember 09, 2025

Testing multiple versions of Python in parallel

Daniel Roy Greenfeld wrote about how to test your code for multiple versions of Python using `uv`. I follow up with a small improvement to the Makefile.

via Technically PersonalJuly 21, 2025

LLDB's TypeSystems Part 2: PDB

In my previous post, I described implementing PDB parsing as a can of worms. That might have been a bit of an understatement. PDB has been one "oh, it's gonna be twice as much work as I thought" after another. Implementing it has revealed many of the same …

via Cracking the ShellJuly 07, 2025

#Rx Writing Challenge 2025

This is a short reflection on my experience of the recent writing challenge I took part in. Over the past two weeks, I have participated in the #RxWritingChallenge 1—a daily, 30-minute writing group starting at 9 AM every morning. Surrounded by fellow doct…

via Ul-lingaApril 05, 2025

My coffee workflow

My coffee workflow by Clement Delafargue on April 1, 2025 Tagged as: coffee, espresso, flair58, v60. It is my first April cools’ and I guess I could start by talking about coffee. If you’ve seen me in person, it won’t be a surprise, I guess. This po…

via Clément Delafargue - RSS feedApril 01, 2025

Generated by openring-rs

favicon here hometagsblogmicrobio cvtech cvgpg keys