Building a REST API in Rust with Axum

Rust has quickly become one of the most talked about programming languages in recent years. It consistently ranks as one of the most admired among developers: in the Stack Overflow Developer Survey it has topped the charts for several years in a row, and the JetBrains Developer Ecosystem Survey highlights its strong growth and adoption across different industries. Developers praise Rust for combining low level performance with modern ergonomics, and for its unique approach to memory safety without garbage collection.
Rust is most widely known as a systems programming language, powering operating systems, embedded software, and performance critical applications. But it is less known as a language for backend development, even though it has the tools and ecosystem to shine in that area as well. With frameworks like Axum, building web APIs in Rust has become approachable and productive.
To explore this side of Rust, we will build a simple Dog Tricks API. This API will let us create, update, find, list, and delete dog tricks, each of which has a title, description, and step by step instructions. It is not meant to be a production ready service, but rather a guided example that shows how easy it can be to set up an HTTP server, define routes, and work with JSON in Rust. By the end, you will have a backend that works and is easy to extend, much like taking your dog from sit to roll over.
Project Setup
Rust comes with a tool called Cargo, which is both a package manager and a build system. With Cargo you can create new projects, compile code, run programs, manage dependencies, and run tests. If you have worked with JavaScript or Python, you can think of it as being similar to npm or pip, but tailored specifically for Rust and used by every Rust project.
Let us start by creating a new project:
cargo new simple-dog-tricks-apiThis gives us a small but complete Rust project. Inside, we will find a src folder containing a main.rs file which is the entry point of the program, and a Cargo.toml file which acts as the project’s manifest.
The Cargo.toml file is where we declare our dependencies. Whenever we run cargo build or cargo run, Cargo looks at this file, fetches the required crates from crates.io, and compiles everything together.
For our Dog Tricks API we will add Axum as the web framework, Tokio as the async runtime, Serde for JSON handling, and UUIDs to identify tricks. After editing, the Cargo.toml looks like this:
[package]
name = "simple-dog-tricks-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
serde = { version = "1.0.226", features = ["derive"] }
uuid = { version = "1.18.1", features = ["serde", "v4"] }
With the project scaffolded and dependencies in place, we are ready to think about how to represent tricks in Rust.
Designing the Data Model
Our API will manage tricks. Each trick has a unique identifier, a title, a description, and a list of instructions. Every instruction itself has a name and a description, so we can describe the steps clearly.
For working with JSON we will use a crate called Serde. Its name comes from Serialization and Deserialization, which are the two directions of data conversion it handles. In practice, Serde helps us move data back and forth between Rust and the outside world: when a client sends JSON, Serde unpacks it into Rust structs we can use; when our API responds, Serde packs those structs back into JSON. While we will stick to JSON in this post, Serde can do the same with other formats such as YAML, TOML, or even XML.
To make this work, we annotate our structs with #[derive(Serialize, Deserialize)]. These are called derive macros. A derive macro is Rust’s way of generating boilerplate code for us at compile time. Instead of manually writing out how each field of a struct should be serialized or deserialized, we can simply derive the necessary traits. The compiler then expands the macro into the required implementation behind the scenes.
Here are the models for our Dog Tricks API:
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Clone)]
pub struct Instruction {
pub name: String,
pub description: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Trick {
pub id: Uuid,
pub title: String,
pub description: String,
pub instructions: Vec<Instruction>,
}
#[derive(Serialize, Deserialize)]
pub struct TrickCreateInput {
pub title: String,
pub description: String,
pub instructions: Vec<Instruction>,
}
#[derive(Serialize, Deserialize)]
pub struct TrickReplaceInput {
pub title: String,
pub description: String,
pub instructions: Vec<Instruction>,
}
Managing Data
So far, we have defined what a trick looks like, but we still need a place to keep them. An API that forgets everything as soon as the request ends would not be very useful. We need a way to manage our tricks across multiple requests.
For this first version of the Dog Tricks API, we will keep things simple and store tricks in memory. Later, we could swap this out for a real database, but the structure will stay the same. The important part is keeping the data management logic separate from our request handlers. That way, the handlers focus only on HTTP details, while the data layer focuses on storing and retrieving tricks.
We will create a small struct called TrickRepository. Internally, it uses a HashMap to hold all tricks, wrapped in a tokio::sync::RwLock for safe concurrent access across async requests. The reason we use an RwLock here is that many requests may access the repository at the same time. One client might be creating a new trick, another listing them, and another deleting one. An RwLock allows multiple readers to access the data at the same time, but ensures that only one writer can modify it. Since our server runs on Tokio’s async runtime, we rely on the async aware version of the lock, which suspends tasks while waiting instead of blocking threads, keeping the server responsive under load.
Here is the repository:
use crate::trick_models::{Trick, TrickCreateInput, TrickReplaceInput};
use std::collections::HashMap;
use tokio::sync::RwLock;
use uuid::Uuid;
pub struct TrickRepository {
store: RwLock<HashMap<Uuid, Trick>>,
}
impl TrickRepository {
pub fn new() -> TrickRepository {
Self {
store: RwLock::new(HashMap::new()),
}
}
pub async fn create(&self, input: TrickCreateInput) -> Trick {
let new_trick = Trick {
id: Uuid::new_v4(),
title: input.title,
description: input.description,
instructions: input.instructions,
};
let mut store = self.store.write().await;
store.insert(new_trick.id.clone(), new_trick.clone());
new_trick
}
pub async fn replace(&self, id: Uuid, input: TrickReplaceInput) -> Trick {
let trick_to_replace = Trick {
id: id.clone(),
title: input.title,
description: input.description,
instructions: input.instructions,
};
let mut store = self.store.write().await;
store.insert(id, trick_to_replace.clone());
trick_to_replace
}
pub async fn find_all(&self) -> Vec<Trick> {
let store = self.store.read().await;
store.values().cloned().collect()
}
pub async fn find_by_id(&self, id: Uuid) -> Option<Trick> {
let store = self.store.read().await;
store.get(&id).cloned()
}
pub async fn delete_by_id(&self, id: Uuid) {
let mut store = self.store.write().await;
store.remove(&id);
}
}
Writing the Handlers
In Axum, the functions that deal with incoming HTTP requests are called handlers. A handler is just an async function that receives some input, like JSON data or a path parameter, and returns a response. Axum takes care of wiring everything up: it deserializes the request body, extracts path or query parameters, and then passes them to the handler. Whatever the handler returns gets turned into an HTTP response.
Here are the handlers for our Dog Tricks API:
use crate::trick_models::{Trick, TrickCreateInput, TrickReplaceInput};
use crate::trick_repository::TrickRepository;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use std::sync::Arc;
use uuid::Uuid;
pub async fn create_trick(
State(repo): State<Arc<TrickRepository>>,
Json(input): Json<TrickCreateInput>,
) -> Json<Trick> {
let new_trick = repo.create(input).await;
Json(new_trick)
}
pub async fn find_tricks(State(repo): State<Arc<TrickRepository>>) -> Json<Vec<Trick>> {
let all_tricks = repo.find_all().await;
Json(all_tricks)
}
pub async fn find_trick_by_id(
State(repo): State<Arc<TrickRepository>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let trick = repo.find_by_id(id).await;
match trick {
None => StatusCode::NOT_FOUND.into_response(),
Some(trick) => Json(trick).into_response(),
}
}
pub async fn replace_trick(
State(repo): State<Arc<TrickRepository>>,
Path(id): Path<Uuid>,
Json(input): Json<TrickReplaceInput>,
) -> Json<Trick> {
let replaced_trick = repo.replace(id, input).await;
Json(replaced_trick)
}
pub async fn delete_trick(State(repo): State<Arc<TrickRepository>>, Path(id): Path<Uuid>) {
repo.delete_by_id(id).await
}
At first glance, you will notice some unusual argument types: State, Json, and Path. These are called extractors in Axum. Extractors tell Axum how to pull information out of the incoming request and pass it into the handler.
State<T>gives the handler access to shared application state, in our case the repository. You might have noticed that the state type is not just aTrickRepository, but anArc<TrickRepository>. That is because the state needs to be clonable. Axum gives each handler its own copy of the state, but we do not want to create a brand new repository for every request. Wrapping it in anArclets all handlers share the same repository instance safely across tasks. If you have worked with frameworks like Spring or NestJS, you can think ofState<T>as a very lightweight form of dependency injection. Instead of a full container, Axum passes the state you provide into your handlers, so you still get the same feeling of injecting dependencies where they are needed.Json<T>tells Axum to take the request body, parse it as JSON, and deserialize it into the given type. It also works the other way around. If a handler returnsJson<T>, Axum serializes the Rust struct back into JSON for the response.Path<T>extracts values from the request path. For example, if the route is defined as/tricks/{id}, Axum automatically parses the{id}part into aUuidand passes it to the handler.
There is one more detail worth pointing out: the find_trick_by_id handler returns impl IntoResponse instead of a concrete type likeJson<Trick>. This is because we want to return two very different outcomes: if the trick exists, we send back JSON with a 200 OK, but if it does not, we send back only a 404 Not Found status. By returning impl IntoResponse, Axum lets us handle both cases in one function while still producing a valid HTTP response.
Inside the repository, the RwLock ensures safe concurrent access to the data. Outside, the Arc ensures that all handlers point to the same repository instance, rather than each having their own. Together, they give us shared, thread safe state that works smoothly in an async environment.
Putting It All Together
Finally, we wire everything up. We create a single repository instance, wrap it in an Arc, and pass it into the application state. Then we define the routes that make our handlers accessible over HTTP.
In Axum, routes are defined with functions like get, post, put, and delete. Each route is associated with a handler, which determines what happens when that endpoint is called. For example, in our setup we say that POST /tricks should call the create_trick handler, while GET /tricks should list all tricks. The GET /tricks/{id} route looks up one trick by ID, PUT /tricks/{id} performs a full replace through the replace_trick handler, and DELETE /tricks/{id} removes a trick.
use crate::trick_handlers::{
create_trick, delete_trick, find_trick_by_id, find_tricks, replace_trick,
};
use crate::trick_repository::TrickRepository;
use axum::routing::get;
use axum::{Router, serve};
use std::sync::Arc;
use tokio::net::TcpListener;
mod trick_handlers;
mod trick_models;
mod trick_repository;
#[tokio::main]
async fn main() {
let repo = Arc::new(TrickRepository::new());
let router = Router::new()
.route("/tricks", get(find_tricks).post(create_trick))
.route(
"/tricks/{id}",
get(find_trick_by_id)
.put(replace_trick)
.delete(delete_trick),
)
.with_state(repo);
let tcp_listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
println!("Server started on port 8080");
serve(tcp_listener, router).await.unwrap();
}
A few things are happening here:
#[tokio::main]turns ourmainfunction into an asynchronous entry point, powered by the Tokio runtime. This is what allows our server to handle many requests concurrently.- We create the repository and pass it into the application state with
.with_state(repo). - We define two groups of routes:
/trickssupportsPOSTto create a trick andGETto list all tricks./tricks/{id}supportsGETto fetch one trick by ID,PUTto replace a trick, andDELETEto remove one.
- Finally, we bind the server to
0.0.0.0:8080and start listening for requests.
At this point, our Dog Tricks API is ready to respond to HTTP requests. Each request flows through the router into the correct handler, and from there into the repository.
Trying It Out
Start the server:
cargo runCreate a trick:
curl -sX POST localhost:8080/tricks \
-H 'content-type: application/json' \
-d '{
"title": "Sit",
"description": "The Sit command is one of the most fundamental and useful tricks for dogs...",
"instructions": [
{"name": "Get attention", "description": "Say the name of the dog. Mark the moment they look at you..."},
{"name": "Lure", "description": "Hold a treat close to the nose..."},
{"name": "Release", "description": "Say OK or FREE to let the dog know they can move..."}
]
}' | jqFind all tricks:
curl -s localhost:8080/tricks | jqFind one trick by ID:
curl -s localhost:8080/tricks/<TRICK_ID> | jqReplace a trick:
curl -sX PUT localhost:8080/tricks/<TRICK_ID> \
-H 'content-type: application/json' \
-d '{
"title": "High Five",
"description": "The High Five trick is a fun and interactive way to engage with your dog...",
"instructions": [
{ "name": "Prepare", "description": "Ask your dog to sit in front of you with attention on your hand." },
{ "name": "Paw", "description": "Hold out your hand. When your dog raises a paw to touch it, say High Five." },
{ "name": "Reward", "description": "Immediately reward your dog with praise and a treat." }
]
}' | jqDelete a trick:
curl -i -X DELETE localhost:8080/tricks/<TRICK_ID>Conclusion
In just a short journey, we went from nothing to a working REST API. With Axum, setting up an HTTP server feels straightforward. You define your routes, write a few small handlers, and you have an API that answers real requests. Along the way, we saw how to model our data, structure our code around handlers and a repository, and keep the logic clean and focused.
The beauty here is how little it takes to get started. Rust gives you safety and performance, while Axum provides the glue to turn those strengths into a web service without unnecessary complexity. Even with only a handful of functions, we have created an API that could already power a small app, and that can easily be extended with databases, authentication, or more advanced features later on.
What matters most is this: building a REST API in Rust with Axum is not only possible but approachable. With the foundation you have seen here, you can take the next steps to grow your project into a more complete backend service.
For those who prefer to dive directly into the code, I have published the full example in a GitHub repository: Simple Dog Tricks API on GitHub.