Skip to main content

One post tagged with "Rust"

View All Tags

Building a Todo API with Rust - A Step-by-Step Guide Using Axum and Diesel

· 7 min read
Huseyin BABAL
Software Developer

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.
tip

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.

up.sql
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL
);
down.sql
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

src/schema.rs
// @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.

src/main.rs
#[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.

src/handlers.rs
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

src/handlers.rs
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

src/handlers.rs
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.

src/handlers.rs
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.

src/handlers.rs
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!

tip

You can find the complete source code for this project on GitHub.