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:

  • Rust: Install it from here.
  • Docker: Install it from here.

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!