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

Addressing the harassment

Kiwi Farms is a web forum that facilitates the discussion and harassment of online figures and communities. Their targets are often subject to organized group trolling and stalking, as well as doxing and real-life harassment. Kiwi Farms has been tied to th…

via Drew DeVault's blogApril 21, 2026

Odin's Fiasco with Wikipedia

Recently, Brodie Robertson produced a video on the Bizarre World of Wikipedia Deleting Programming Pages. I highly recommend watching the video.I thank Brodie for covering the Wikipedia fiasco for Odin. We don;t particularly care if Odin is on Wikipedia or…

via gingerBill - ArticlesApril 20, 2026

[WFD 42] Atlas: structural code-intelligence for LLM agents (an empirical evaluation)

2,239-trial benchmark across 8 OSS repos: Atlas beats a text-search baseline by +0.223 deterministic, +0.127 LLM-judge, at 42% fewer tokens.

via Ryana May Que — Writings for DiscussionApril 19, 2026

Eleventy

When I started this blog in 2011, I built it using Jekyll. Jekyll served me well for fifteen years. It was fast enough, and though it would take me an hour or two to get the system reinstalled when I switched laptops, it mostly just worked. But late last y…

via macwright.comApril 17, 2026

How to create a slick CSS animation from Star Wars

I will make a CSS animation of the iconic opening title sequence for the movie Star Wars. I focus on the text crawl portion of the sequence, which introduces the general plot. I will make it responsive.

via Rob O'Leary | BlogApril 16, 2026

Hybrid Constructions: The Post-Quantum Safety Blanket

The funny thing about safety blankets is they can double as stage curtains for security theater. “When will a cryptography-relevant quantum computer exist?” is a question many technologists are pondering as they stare into crystal balls or entrails. Two pe…

via Dhole MomentsApril 13, 2026

Bucklog’s Machine: Inside a Kubernetes Scanning Fleet

Most scanning infrastructure is boring. A VPS, a cron job, maybe a cheap proxy rotation service if the operator has ambitions. What we’re looking at with AS211590 (Bucklog SARL / FBW Networks SAS) is something else entirely – a purpose-built, Kubernetes-or…

via GreyNoise LabsMarch 23, 2026

Status update, February 2026

Hi all! Lars has contributed an implementation independent test suite for the scfg configuration file format. This is quite nice for implementors, they get a base test suite for free. I’ve added support for it for libscfg, the C implementation. I’ve spent …

via emersionFebruary 21, 2026

Investigating the SuperNote Notebook Format

I'm a big fan of eink tablets. I read a lot, I write a lot, I prefer handwritten notes, it's a match made in heaven. I've been using a Kindle Scribe for the past several years - I probably used it as much or more than my phone. Recently, I upgraded to a Su…

via Cracking the ShellFebruary 20, 2026

Luxe, ocaml et volupté

Luxe, ocaml et volupté by Clément Delafargue on February 16, 2026 Tagged as: ocaml. After a couple years using rust as my primary language, I’ve got a new job where I’m using a variety of languages (including rust and typescript), but mostly go 1. So…

via Clément Delafargue - RSS feedFebruary 16, 2026

How To Add DRM To Your Backend (easy) [2026 WORKING]

How KineMaster stopped some modded clients from accessing their asset market

via maia blogFebruary 14, 2026

Push comes to shove tools

Your tools are extensions of your skills

via Ishan WritesFebruary 09, 2026

2025 in review

Come along with me as I review the past year. Heh, I often start these kinds of posts right at the start of the year, but it takes a few weeks longer than I ever expect to think them through.1 Two years of being independent After a second year of operati…

via seanmonstarJanuary 27, 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

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

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

Hacking the World Poker Tour: Inside ClubWPT Gold’s Back Office

In June, 2025, Shubs Shah and I discovered a vulnerability in the online poker website ClubWPT Gold which would have allowed an attacker to fully access the core back office application that is used for all administrative site functionality.

via Blog | Sam CurryOctober 12, 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

Generated by openring-rs

favicon here hometagsblogmicrobio cvtech cvgpg keys