Building a Todo API with Rust - A Step-by-Step Guide Using Axum and Diesel
Introduction
In the world of web development, performance and safety are paramount. Rust, with its emphasis on speed and memory safety, has emerged as a powerful language for building robust web applications. Today, we'll explore how to create a high-performance RESTful API for a Todo application using Rust, along with two of its most popular libraries: Axum for web services and Diesel for ORM. Rust: A systems programming language that runs blazingly fast and prevents segfaults. Axum: A web application framework that focuses on ergonomics and modularity. Diesel: A safe, extensible ORM and Query Builder for Rust.
Prerequisites
- Rust
- Cargo for package management
- Diesel
- PostgreSQL In this article, we will be using PostgreSQL as our database. You can maintain your database in any database management system. For a convenient deployment option, consider cloud-based solutions like Rapidapp, which offers managed PostgreSQL databases, simplifying setup and maintenance.
Create a free database in Rapidapp in seconds here
Getting Started
You can initialize the project and add required dependencies as follows;
# Intialize the project
cargo new todo-rs
cd todo-rs
# Add dependencies
cargo add \
axum \
tokio \
serde \
serde_json \
diesel \
dotenvy \
-F tokio/full,serde/derive,diesel/postgres,diesel/r2d2
cargo add
is used for dependencies, and if you also add modules for specific crate (package), then we use -F
param.
For example, if we want to include postgres
feature of diesel, the notation will be diesel/postgres
.
Above command will populate Cargo.toml
file as follows;
[package]
name = "todo-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
axum-macros = "0.4.1"
diesel = { version = "2.2.2", features = ["postgres", "r2d2"] }
dotenvy = "0.15.7"
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["full"] }
DB Migration with Diesel
You can initialize the migration for your project for the first time with the following;
diesel setup
This will create migrations
folder and diesel.toml
in project root folder.
In this article, we will implement a Todo REST API, and the only business model we have is todos
. In order to generate
migrations for todos entity, we can use following command.
diesel migration generate create_todos_table
This will generate a dedicated migration folder for todos table creation. When you open migration folder, you will se there is
up.sql
and down.sql
files. up.sql
is executed once we run the migration to apply DB changes. down.sql
is used once we
revert the DB changes. We are responsible for those sql
file as you can see below.
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL
);
DROP TABLE todos;
Now we can run diesel migration run
to apply migrations. This will create the table and also will create a src/schema.rs
file contains mapped struct for todo entity as follows
// @generated automatically by Diesel CLI.
diesel::table! {
todos (id) {
id -> Int4,
title -> Text,
content -> Text,
}
}
You can clearly see, it is generated bt Diesel and you shouldn't manually configure it.
Implement Axum Server
In this section we will be implementing that part responsible for running an HTTP server with axum. This server will expose the handlers for the CRUD operations of Todo entity.
#[tokio::main]
async fn main() {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = r2d2::Pool::builder()
.max_size(5)
.build(manager)
.expect("Failed to create pool.");
let db_connection = Arc::new(pool);
let app = Router::new()
.route("/todos", post(handlers::create_todo))
.route("/todos", get(handlers::get_todos))
.route("/todos/:id", get(handlers::get_todo))
.route("/todos/:id", post(handlers::update_todo))
.route("/todos/:id", delete(handlers::delete_todo))
.with_state(db_connection.clone());
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
let server = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal());
tokio::spawn(async move {
println!("Server is running");
});
if let Err(e) = server.await {
eprintln!("Server error: {}", e);
}
}
Line 4-10: Set up the database connection pool
Line 12-18: Define the routes for our API
Line 20-21: Set up the server address
Line 23-30: Log application startup or failure
src/main.rs
is the file we mostly do our global initializations like database connection pooling setup or preparing REST
endpoints. Now that we have endpoints for the Todo entity, let's implement the real logic of those handlers.
Implementing Handlers
Create Todo Handler
In this handler, we accept NewTodo
request and will create new record in database. In axum handlers, you can see a state
beside request body and they are used for passing dependencies like database connection pools to use for db operations.
pub async fn create_todo(
State(db): State<DbPool>,
Json(new_todo): Json<NewTodo>,
) -> (StatusCode,Json<Todo>) {
let mut conn = db.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
let todo = diesel::insert_into(todos::table)
.values(&new_todo)
.get_result(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
(StatusCode::CREATED, Json(todo))
}
Line 2: Accept db connection pool as dependency
Line 3: Request body as NewTodo
Line 5: Get available connection from DB connection pool, throw error otherwise.
Line 7: Insert new_todo
in todos
table
Line 12: Return CREATED
status code and new todo item as response body
List Todos Handler
pub async fn get_todos(
State(db): State<DbPool>,
) -> (StatusCode,Json<Vec<Todo>>) {
let mut conn = db.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
let results = todos::table.load::<Todo>(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
(StatusCode::OK, Json(results))
}
This time, we don't expect to see something in body, we just return todos items by using load
function and cast them to
Todo
struct. As always, return results in response body with status code OK
Get Todo Handler
We get the todo id from path params and do a query to todos table by filtering id as follows
pub async fn get_todo(
Path(todo_id): Path<i32>,
State(db): State<DbPool>,
) -> (StatusCode,Json<Todo>) {
let mut conn = db.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
let result = todos::table.filter(id.eq(todo_id)).first::<Todo>(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
(StatusCode::OK, Json(result))
}
Update Todo Handler
In this handler, we accept update payload from end user and update existing Todo by resolving the id from path params.
pub async fn update_todo(
Path(todo_id): Path<i32>,
State(db): State<DbPool>,
Json(update_todo): Json<UpdateTodo>,
) -> (StatusCode,Json<Todo>) {
let mut conn = db.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
let todo = diesel::update(todos::table.filter(id.eq(todo_id)))
.set(&update_todo)
.get_result(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
(StatusCode::OK, Json(todo))
}
Delete Todo Handler
As you guess, we resolve todo id from path params then execute delete query against todo table as follows.
pub async fn delete_todo(
Path(todo_id): Path<i32>,
State(db): State<DbPool>,
) -> StatusCode {
let mut conn = db.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
let _ =diesel::delete(todos::table.filter(id.eq(todo_id)))
.execute(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR).unwrap();
StatusCode::NO_CONTENT
}
Demo Time
Right after you set environment variable DATABASE_URL
, you can run application as follows;
cargo run
Here are some Todo operations
Create a todo
curl -X POST -H "Content-Type: application/json" -d '{"title":"Buy groceries","content":"banana,milk"}' http://localhost:8080/todos
List all todos
curl http://localhost:8080/todos
Get a specific todo
curl http://localhost:8080/todos/1
Update a todo
curl -X POST -H "Content-Type: application/json" -d '{"title":"Buy Groceries", "content": "banana"}' http://localhost:8080/todos/1
Delete a todo
curl -X DELETE http://localhost:8080/todos/1
Conclusion
We've successfully built a Todo API using Rust, Axum, and Diesel. This combination provides a robust, safe, and efficient backend for web applications. The strong typing of Rust, combined with Diesel's compile-time checked queries and Axum's ergonomic routing, creates a powerful foundation for building scalable web services. By leveraging Rust's performance and safety features, we can create APIs that are not only fast but also resistant to common runtime errors. As you continue to explore Rust for web development, you'll find that this stack provides an excellent balance of developer productivity and application performance. Remember, this is just the beginning. You can extend this API with authentication, more complex queries, and additional features to suit your specific needs. Happy coding!
You can find the complete source code for this project on GitHub.