DEV Community

Cover image for Rust CRUD Rest API, using Axum, sqlx, Postgres, Docker and Docker Compose
Francesco Ciulla
Francesco Ciulla

Posted on

Rust CRUD Rest API, using Axum, sqlx, Postgres, Docker and Docker Compose

Let's create a CRUD Rest API in Rust, using the following:

  • Axum (Rust web framework)
  • sqlx (ORM)
  • Postgres (database)
  • Docker (containerization)
  • Docker Compose (to run the application and the database in containers)

If you prefer a video version:

All the code is available in the GitHub repository (link in the video description):


🏁 Intro

Here is a schema of the architecture of the application we are going to create:

crud, read, update, delete, to a flask app and postgres service, connected with docker compose. Postman and tableplus to test it

We will create 5 endpoints for basic CRUD operations:

  • Create
  • Read all
  • Read one
  • Update
  • Delete

We will also create a simple endpoint to test if the server is running.

Here are the steps we are going through:

  • Create a compose.yml file and ru the Postgres instance
  • Create an Axum application using sqlx as an ORM
  • Dockerize the Axum application
  • Run the Axum application using docker compose
  • Test the application

We will go with a step-by-step guide, so you can follow along.


πŸ›‘ Prerequisites

Before starting, make sure you have the following installed on your machine:

  • Docker
  • Rust and Cargo

🏁 Project initialization

Let's iitialize the project:

cargo new axumlive
Enter fullscreen mode Exit fullscreen mode

This will create a new folder called axumlive with the following structure:

axumlive
β”‚   Cargo.toml
└───src
    β”‚   main.rs
Enter fullscreen mode Exit fullscreen mode

Run the Postgres container

Let's run the Postgres container first, so we have the database ready when we will run the Axum application.

To do that, create a file called compose.yml in the root of the project (axumlive folder) with the following content:

  db:
    image: postgres:15
    container_name: db
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: simple_api
    volumes:
      - pg_data:/var/lib/postgresql/data

volumes:
  pg_data:
Enter fullscreen mode Exit fullscreen mode

Now, to run the Postgres container, type:

docker compose up -d db
Enter fullscreen mode Exit fullscreen mode

This will run the Postgres container in detached mode (in the background).

If you see something like this, it means that the container is running:

docker downlading image - Build a CRUD Rest API in Python using Flask, SQLAlchemy, Postgres, Docker

Now, to check if the container is running, type:

docker ps -a
Enter fullscreen mode Exit fullscreen mode

If everything is ok, you should see something like this:

one container running

Now before we write our Axum application, let's step inside the Postgres container:

docker exec -it db psql -U user -d simple_api
Enter fullscreen mode Exit fullscreen mode

This will open the Postgres shell.

You should see something like this:

one container running

Now type:

\dt
Enter fullscreen mode Exit fullscreen mode

And you should see "didn't find any relations" because the database is empty.

one container running

This is normal but let's keep this terminal open somewhere. We will use it later to check if the tables are created correctly.


πŸ“¦ Add dependencies and the Sql migrations

Let's add the dependencies we need to the Cargo.toml file.

...
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Since we want a convenient way to create the tables in the database, we will use sqlx migrations. This allows us to create the table the first time we run the application. This is also convenient in case someone else wants to run the application, they just have to run the migrations and the tables will be created automatically.

Now, let's create a folder called migrations in the root of the project (axumlive folder) and inside it create a file called 0001_users_table.sql with the following content:

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);
Enter fullscreen mode Exit fullscreen mode

We are now ready to write the Axum application.

πŸ“¦ Add The Dependncies

Open the Cargo.toml file and add the following dependencies:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

πŸ¦€ Create the Axum backend

Let's open the main.rs file and populate it with the following code:

use axum::{ extract::{ Path, State }, http::StatusCode, routing::{ get, post }, Json, Router };
use serde::{ Deserialize, Serialize };
use sqlx::{ postgres::PgPoolOptions, FromRow, PgPool };
use std::env;

#[derive(Deserialize)]
struct UserPayload {
    name: String,
    email: String,
}

#[derive(Serialize, FromRow)]
struct User {
    id: i32,
    name: String,
    email: String,
}

#[tokio::main]
async fn main() {
    let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = PgPoolOptions::new().connect(&db_url).await.expect("Failed to connect to DB");
    sqlx::migrate!().run(&pool).await.expect("Migrations failed");

    let app = Router::new()
        .route("/", get(root))
        .route("/users", post(create_user).get(list_users))
        .route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
        .with_state(pool);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    println!("πŸš€ Server running on port 8000");
    axum::serve(listener, app).await.unwrap();
}

//Endpoint Handlers
//test endpoint
async fn root() -> &'static str {
    "Welcome to the User Management API!"
}

//GET ALL
async fn list_users(State(pool): State<PgPool>) -> Result<Json<Vec<User>>, StatusCode> {
    sqlx::query_as::<_, User>("SELECT * FROM users")
        .fetch_all(&pool).await
        .map(Json)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

//CREATE USER
async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<UserPayload>
    ) -> Result<(StatusCode, Json<User>), StatusCode> {
    sqlx::query_as::<_, User>("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *")
        .bind(payload.name)
        .bind(payload.email)
        .fetch_one(&pool).await
        .map(|u| (StatusCode::CREATED, Json(u)))
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

//GET USER BY ID
async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<i32>
    ) -> Result<Json<User>, StatusCode> {
    sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(&pool).await
        .map(Json)
        .map_err(|_| StatusCode::NOT_FOUND)
}

//UPDATE USER
async fn update_user(
    State(pool): State<PgPool>,
    Path(id): Path<i32>,
    Json(payload): Json<UserPayload>
    ) -> Result<Json<User>, StatusCode> {
    sqlx::query_as::<_, User>("UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *")
        .bind(payload.name)
        .bind(payload.email)
        .bind(id)
        .fetch_one(&pool).await
        .map(Json)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

//DELETE USER
async fn delete_user(
    State(pool): State<PgPool>,
    Path(id): Path<i32>
) -> Result<StatusCode, StatusCode> {
    let result = sqlx
        ::query("DELETE FROM users WHERE id = $1")
        .bind(id)
        .execute(&pool).await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if result.rows_affected() == 0 {
        Err(StatusCode::NOT_FOUND)
    } else {
        Ok(StatusCode::NO_CONTENT)
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • We define the data structures for the user payload and the user model using Serde for serialization and deserialization.
  • We set up the database connection using sqlx and run the migrations to create the users table
  • We define the Axum routes for the CRUD operations and associate them with their respective handler functions
  • Each handler function interacts with the database using sqlx to perform the required operations and returns appropriate HTTP responses.
  • Finally, we start the Axum server on port 8000. ___ ## 🐳 Dockerize the Axum application

It's time to Dockerize the Rust application. To do this, first let's create a .dockerignore file in the root of the project (axumlive folder) with the following content:

target
.git
Enter fullscreen mode Exit fullscreen mode

This will prevent Docker from copying the target folder and the .git folder into the Docker image, which are not needed.

In the root folder of the project (axumlive), create a file called Dockerfile
This one will be a multi-stage Dockerfile, to keep the final image as small as possible.

Let's populate the Dockerfile

Dockerfile:

#stage 1
FROM rust:1.91 as builder
WORKDIR /app
COPY . .
RUN cargo build --release


#stage 2
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/axumlive .
EXPOSE 8000
CMD ["./axumlive"]
Enter fullscreen mode Exit fullscreen mode

🐳🐳Docker compose

Since now we defined the Axum application and we dockerized it, it's time to add this service to the compose.yml file, so we can run both the Axum application and the Postgres database with a single command.

Populate the docker-compose.yml file:

services:
  app:
    container_name: simple_axum
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://user:password@db:5432/simple_api
  db:
    image: postgres:15
    container_name: db
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: simple_api
    volumes:
      - pg_data:/var/lib/postgresql/data

volumes:
  pg_data:
Enter fullscreen mode Exit fullscreen mode

We defined a new service called "app" which is the Axum application. We set the build context to the current directory (.) and we map the port 8000 of the container to the port 8000 of the host machine.


πŸ‘Ÿ Build and Run the Axum appliation

Now let's build so we can run the Axum application.

Let's type

docker compose build
Enter fullscreen mode Exit fullscreen mode

And you should see something like this:

docker build

This should BUILD the app image, with the name defined in the "image" value, in this case, ""simple_axum".

You can also see all the steps docker did to build the image, layer by layer. You might recognize some of them, because we defined them in the Dockerfile.

Now let's run the app service:

docker compose up -d app
Enter fullscreen mode Exit fullscreen mode

This will run the app service in detached mode (in the background).

two containers running

Let's check if both the containrs are running:

docker ps -a
Enter fullscreen mode Exit fullscreen mode

Important step: is you go back to the psql terminal where we stepped into the Postgres container, and type:

\dt
Enter fullscreen mode Exit fullscreen mode

you should now see the users table created by the migration:

users table created

Of course, this talbe is currenlty empty, but at least we know that the migration ran successfully when the Axum application started.


πŸ” Test the application

Now let's test our application.

The first step is to visit http://localhost:8000/ in your browser:

users table created

You can tet it in different ways. In this specific case, I will use a VS Code extension called REST Client, but you can use Postman, curl or any other tool you prefer.

You can test the application in the way you prefer, but for convenience I will add a file you can copy paste in your application at the root level, called request.hhtp, with all the requests we need to test the applicationL

// requests.http
@baseUrl = http://localhost:8000

### 1. Test Root Endpoint (Health Check)
GET {{baseUrl}}/

### 2. Get All Users (Should be empty initially)
GET {{baseUrl}}/users

### 3. Create User 1
POST {{baseUrl}}/users
Content-Type: application/json

{
    "name": "Alice Smith",
    "email": "alice@example.com"
}

### 4. Create User 2
POST {{baseUrl}}/users
Content-Type: application/json

{
    "name": "Bob Jones",
    "email": "bob@example.com"
}

### 5. Create User 3
POST {{baseUrl}}/users
Content-Type: application/json

{
    "name": "Charlie Day",
    "email": "charlie@example.com"
}

### 6. Get All Users (Should see 3 users now)
GET {{baseUrl}}/users

### 7. Get Single User (Assuming ID 1 exists)
GET {{baseUrl}}/users/1

### 8. Get User that does not exist (Test 404)
GET {{baseUrl}}/users/9999

### 9. Update User 2
# Note: Ensure your backend handles PUT or PATCH for updates
PUT {{baseUrl}}/users/2
Content-Type: application/json

{
    "name": "Bob James",
    "email": "bobjames@example.com"
}

### 10. Get All Users (Verify Update)
GET {{baseUrl}}/users

### 11. Delete User 2
DELETE {{baseUrl}}/users/3

### 12. Get All Users (Final check - Bob should be gone)
GET {{baseUrl}}/users
Enter fullscreen mode Exit fullscreen mode

You can test the example Endoiunt by runnig the first request in the file
, which is a GET request to localhost:8000/

GET request to localhost:4000/


πŸ“ Create a user



Now let's create a user, making a POST request to localhost:8000/users with the body below as a request body:

POST request to localhost:4000/users

Let's create another one:

POST request to localhost:4000/users

One more:

POST request to localhost:4000/users


πŸ“ Get all users



Now, let's make a GET request to localhost:8000/users to get all the users:

GET request to localhost:4000/users

We just created 3 users.

From the browser, we can see:

GET request to localhost:4000/users from browser

If we go back to the psql terminal where we stepped into the Postgres container, and type:

SELECT * FROM users;
Enter fullscreen mode Exit fullscreen mode

we should see the 3 users we just created:

3 users in the users table


πŸ“ Get a specific user



If you want to get a specific user, you can make a GET request to localhost:4000/users/<user_id>.

For example, to get the user with id 1, you can make a GET request to localhost:8000/users/1

GET request to localhost:4000/users/2

If we try to get a user that does not exist, for example the user with id 9999, we should get a 404 error:

GET request to localhost:4000/users/9999


πŸ“ Update a user



If you want to update a user, you can make a PUT request to localhost:8000/users/<user_id>.

For example, to update the user with id 2, you can make a PUT request to localhost:8000/users/2 with the body below as a request body:

PUT request to localhost:4000/users/2


πŸ“ Delete a user



To delete a user, you can make a DELETE request to localhost:4000/users/<user_id>.

For Example, to delete the user with id 3, you can make a DELETE request to localhost:8000/users/3

DELETE request to localhost:4000/users/2

To check if the user has been deleted, you can make a GET request to localhost:8000/users or check directly in the browser:

GET request to localhost:4000/users

As you can see the user with id 3 is not there anymore.


🏁 Conclusion



We made it! We have built a CRUD rest API in Rust using Axum, sqlx, and Postgres, and we dockerized the application.

This is just an example, but you can use this as a starting point to build your own application.

If you prefer a video version:

All the code is available in the GitHub repository (link in the video description): https://youtu.be/cJyl9e2oqHY

That's all.

If you have any question, drop a comment below.

Francesco

Top comments (0)