How to Create a Rust API with Axum, Postgres, and SQLx
In this tutorial, we will walk through building a simple REST API using Axum, a lightweight Rust web framework. This API will support basic CRUD operations (Create, Read, Update, Delete) backed by a Postgres database, all running inside Docker containers. By the end of the article, you'll have a fully functional API that can create, read, update, and delete records in a database.
Step 1: Set Up Your Development Environment
Before we start writing code, ensure that you have the necessary tools installed:
Step 2: Create a New Rust Project
In your terminal, create a new Rust project:
cargo new axum_crud
cd axum_crud
Now, open the Cargo.toml
file and add the following dependencies:
[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.6", features = ["postgres", "runtime-tokio-rustls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
Run cargo build
to ensure everything is correctly added.
Step 3: Set Up the Database with Docker
Now, let's run a Postgres container using Docker. Open your terminal and run:
docker run --name axum-postgres -e POSTGRES_PASSWORD=mysecretpassword -e POSTGRES_DB=axum_crud -p 5432:5432 -d postgres
You can check if the container is running by executing docker ps
. Now, let's configure SQLx to connect to this database.
Step 4: Configure the Database Connection
Create a .env
file in the root of your project and add the following:
DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/axum_crud
Now, create a file src/db.rs
to manage the database connection:
use sqlx::PgPool;
use std::env;
use dotenv::dotenv;
pub async fn create_pool() -> PgPool {
dotenv().ok();
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
PgPool::connect(&database_url)
.await
.expect("Error creating pool")
}
This function reads the database URL from the .env
file and establishes a connection using SQLx.
Step 5: Define the Data Model
Create a new file src/models.rs
to define the Item
struct:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Item {
pub id: i32,
pub name: String,
pub description: String,
}
This struct will be used to serialize and deserialize the data when interacting with the API.
Step 6: Set Up CRUD Operations
Let’s set up the CRUD operations, starting with Create. In src/crud.rs
, add the following:
use crate::db::create_pool;
use crate::models::Item;
use sqlx::query;
pub async fn create_item(name: &str, description: &str) -> Result<Item, sqlx::Error> {
let pool = create_pool().await;
let row = query!(
"INSERT INTO items (name, description) VALUES ($1, $2) RETURNING id, name, description",
name,
description
)
.fetch_one(&pool)
.await?;
Ok(Item {
id: row.id,
name: row.name.expect("Name field is missing"),
description: row.description.expect("Description field is missing"),
})
}
pub async fn get_item(id: i32) -> Result<Item, sqlx::Error> {
let pool = create_pool().await;
let row = query!("SELECT id, name, description FROM items WHERE id = $1", id)
.fetch_one(&pool)
.await?;
Ok(Item {
id: row.id,
name: row.name.expect("Name field is missing"),
description: row.description.expect("Description field is missing"),
})
}
pub async fn update_item(id: i32, name: &str, description: &str) -> Result<Item, sqlx::Error> {
let pool = create_pool().await;
let row = query!(
"UPDATE items SET name = $1, description = $2 WHERE id = $3 RETURNING id, name, description",
name,
description,
id
)
.fetch_one(&pool)
.await?;
Ok(Item {
id: row.id,
name: row.name.expect("Name field is missing"),
description: row.description.expect("Description field is missing"),
})
}
pub async fn delete_item(id: i32) -> Result<(), sqlx::Error> {
let pool = create_pool().await;
query!("DELETE FROM items WHERE id = $1", id)
.execute(&pool)
.await?;
Ok(())
}
In this code, we’ve added the following functions:
- create_item: Inserts a new item into the database.
- get_item: Retrieves an item by
id
. - update_item: Updates an item’s name and description.
- delete_item: Deletes an item from the database.
Step 7: Set Up Routes and Handlers
Now, let’s integrate the CRUD operations with Axum routes in src/main.rs
:
use axum::{
routing::{get, post, put, delete},
Router,
extract::Json,
};
use serde_json::json;
use crate::crud::{create_item, get_item, update_item, delete_item};
use crate::models::Item;
mod crud;
mod db;
mod models;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/items", post(create_item_handler))
.route("/items/:id", get(get_item_handler))
.route("/items/:id", put(update_item_handler))
.route("/items/:id", delete(delete_item_handler));
axum::Server::bind(&"127.0.0.1:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn create_item_handler(Json(payload): Json<Item>) -> impl axum::response::IntoResponse {
match create_item(&payload.name, &payload.description).await {
Ok(item) => axum::response::Json(json!(item)),
Err(_) => axum::response::Json(json!({"error": "Failed to create item"})),
}
}
async fn get_item_handler(axum::extract::Path(id): axum::extract::Path<i32>) -> impl axum::response::IntoResponse {
match get_item(id).await {
Ok(item) => axum::response::Json(json!(item)),
Err(_) => axum::response::Json(json!({"error": "Item not found"})),
}
}
async fn update_item_handler(axum::extract::Path(id): axum::extract::Path<i32>, Json(payload): Json<Item>) -> impl axum::response::IntoResponse {
match update_item(id, &payload.name, &payload.description).await {
Ok(item) => axum::response::Json(json!(item)),
Err(_) => axum::response::Json(json!({"error": "Failed to update item"})),
}
}
async fn delete_item_handler(axum::extract::Path(id): axum::extract::Path<i32>) -> impl axum::response::IntoResponse {
match delete_item(id).await {
Ok(_) => axum::response::Json(json!({"message": "Item deleted"})),
Err(_) => axum::response::Json(json!({"error": "Failed to delete item"})),
}
}
Here’s what the new routes do:
- POST /items: Creates a new item.
- GET /items/:id: Retrieves an item by
id
. - PUT /items/:id: Updates an item by
id
. - DELETE /items/:id: Deletes an item by
id
.
Step 8: Create the Database Schema
Before running the app, let's create the items
table in Postgres. In your Postgres client, run:
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
description TEXT
);
Step 9: Test the API
Run the server:
cargo run
You can now test your API with tools like Postman or curl. Here’s how you can test each endpoint:
-
Create Item:
curl -X POST http://localhost:3000/items -H "Content-Type: application/json" -d '{"id": 0, "name": "Item 1", "description": "Description of Item 1"}'
-
Get Item:
curl http://localhost:3000/items/1
-
Update Item:
curl -X PUT http://localhost:3000/items/1 -H "Content-Type: application/json" -d '{"id": 1, "name": "Updated Item", "description": "Updated description"}'
-
Delete Item:
curl -X DELETE http://localhost:3000/items/1
Conclusion
You've now built a fully functional CRUD API using Axum, Postgres, and SQLx. You’ve learned how to set up a Postgres database with Docker, how to connect to it from your Rust app using SQLx, and how to implement basic CRUD operations. Keep exploring and adding features like authentication, validation, and pagination to improve your app further!