In this tutorial, we will build a CRUD API for a feedback application. This project will help you get started with API development in Rust or enhance your skills as a Rust developer.

We’ll begin by setting up the Rust project with Cargo, then launch an SQL database server using Docker. After that, we’ll perform database migrations to create the necessary records for the feedback app. Next, we will build HTTP routes to handle CRUD requests sent to the server.

This is just an overview of the steps we’ll take to build the feedback API. As we progress, we’ll incorporate additional steps to guide us toward the final result. So, get ready, and let’s dive in!

Related Articles

  1. Building a Rust API with Unit Testing in Mind
  2. How to Write Unit Tests for Your Rust API
  3. How to Add Swagger UI, Redoc and RapiDoc to a Rust API
  4. JWT Authentication and Authorization in a Rust API using Actix-Web
  5. How to Write Unit Tests for Your Rust API
  6. Dockerizing a Rust API Project: SQL Database and pgAdmin
  7. Deploy Rust App on VPS with GitHub Actions and Docker
Build a CRUD API HTTP Server in Rust

Run the API on Your Machine

To run the feedback API project on your local machine and interact with the different endpoints, follow the steps outlined below:

  • Download or clone the project from its GitHub repository at https://github.com/wpcodevo/rust-feedback-api and open the source code in your preferred code editor.
  • Launch the Postgres and pgAdmin Docker containers by running the command docker-compose up -d. If Docker is not already installed on your machine, you can download and install it from their official website.
  • Apply the database migrations to the PostgreSQL database by running sqlx migrate run. If you don’t already have the SQLx-CLI installed on your computer, you can do so by running the command cargo install sqlx-cli --no-default-features --features postgres.
  • Once the database migrations have been completed successfully, run the command cargo run. This command will install the necessary packages and start the Axum development server.

Set Up the Rust API Project

Let’s begin by setting up the Rust API project. Open the integrated terminal in your code editor and run the command below. This will create a new binary application named rust-feedback-api, containing all the necessary files to start building your Rust application:


cargo new --bin rust-feedback-api

Once the project has been generated, the next step is to install the necessary dependencies for building the API. In the terminal, run the following commands to add the required packages:


cargo add axum
cargo add tokio -F full
cargo add tower-http -F "cors"
cargo add serde_json
cargo add serde -F derive
cargo add chrono -F serde
cargo add dotenv
cargo add uuid -F "serde v4"
cargo add sqlx -F "runtime-async-std-native-tls postgres chrono uuid"

These commands will install essential libraries for handling requests, managing asynchronous tasks, serializing data, interacting with the database, and more.

Now, let’s get started by building a basic Axum HTTP server with a single health check route. Open the src/main.rs file and add the following code:

src/main.rs


use axum::{response::IntoResponse, routing::get, Json, Router};

async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Feedback CRUD API with Rust, SQLX, Postgres,and Axum";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

#[tokio::main]
async fn main() {
     let app = Router::new().route("/api/healthchecker", get(health_checker_handler));

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

In the above code, we created an asynchronous function to contain the logic for the health checker route. Within the function, we defined a constant message and constructed a JSON response containing a success status and the message.

Next, we created the Axum server, registered the health checker route, and started the HTTP server on port 8000.

To test the health checker route, visit the URL http://localhost:8000/api/healthchecker in your browser or API client.

Set Up a Database with Docker

Now that we have our basic HTTP server up and running, let’s proceed to set up a PostgreSQL database using Docker. In the root directory of your project, create a docker-compose.yml file and add the following Docker Compose configurations:

docker-compose.yml


services:
  postgres:
    image: postgres:latest
    container_name: postgres
    ports:
      - "6500:5432"
    volumes:
      - progresDB:/var/lib/postgresql/data
    env_file:
      - ./.env
  pgAdmin:
    image: dpage/pgadmin4
    container_name: pgAdmin
    env_file:
      - ./.env
    ports:
      - "5050:80"
volumes:
  progresDB:

In the code above, we included an env_file property to load the necessary environment variables for launching the Postgres and pgAdmin containers. To make these variables accessible to Docker Compose, create a .env file in the root directory with the following secrets:

.env


POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=6500
POSTGRES_USER=admin
POSTGRES_PASSWORD=password123
POSTGRES_DB=rust_sqlx

DATABASE_URL=postgresql://admin:password123@localhost:6500/rust_sqlx?schema=public

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

With the environment variables in place, run the command docker-compose up -d to start the Docker containers.

Connect the Rust App to the Running Database

That was quite a bit of setup boilerplate code! Now, let’s proceed to connect the Rust app to the running database server. Open your src/main.rs file and replace its existing content with the code provided below:

src/main.rs


use std::sync::Arc;

use axum::{response::IntoResponse, routing::get, Json, Router};
use dotenv::dotenv;

use sqlx::{postgres::PgPoolOptions, Pool, Postgres};

pub struct AppState {
    db: Pool<Postgres>,
}

#[tokio::main]
async fn main() {
    dotenv().ok();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = match PgPoolOptions::new()
        .max_connections(10)
        .connect(&database_url)
        .await
    {
        Ok(pool) => {
            println!("✅Connection to the database is successful!");
            pool
        }
        Err(err) => {
            println!("🔥 Failed to connect to the database: {:?}", err);
            std::process::exit(1);
        }
    };

    let app = Router::new().route("/api/healthchecker", get(health_checker_handler)).with_state(Arc::new(AppState { db: pool.clone() }));

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Feedback CRUD API with Rust, SQLX, Postgres,and Axum";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

When you save the file, you should see a warning in your terminal indicating that the db field is never read. This means we are on the right track.

Create and Push the Database Migrations

Now that our app can connect to the database, let’s proceed to the next step: setting up database migrations. This process will enable us to create the necessary table for storing feedback items.

We’ll utilize the SQLx CLI to manage the migrations and apply them to the database. If you haven’t installed the SQLx CLI yet, you can do so by running the following command:


cargo install sqlx-cli --no-default-features --features postgres

With the SQLx CLI installed, execute the command below to generate reversible migration scripts. These scripts will be stored in a folder called migrations within the root directory:


sqlx migrate add -r "init" 

Navigate to the migrations folder and open the file that includes ‘up‘ in its name. Inside this file, add the following SQL code to create a table in the database with columns representing the fields of a feedback item:

migrations/20240814125316_init.up.sql


-- Add up migration script here

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE feedbacks (
    id UUID PRIMARY KEY NOT NULL DEFAULT (uuid_generate_v4()),
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    feedback TEXT NOT NULL UNIQUE,
    rating INT CHECK (rating >= 1 AND rating <= 5) NOT NULL,
    status VARCHAR(50) DEFAULT 'pending',
    created_at TIMESTAMP
        WITH
            TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP
        WITH
            TIME ZONE DEFAULT NOW()
    CONSTRAINT email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);

Next, we need to define the SQL code that will reverse the changes made by the 'up' script. Open the file that includes the 'down' keyword in its name and insert the following code:

migrations/20240814125316_init.down.sql


-- Add down migration script here

DROP TABLE IF EXISTS feedbacks;

Finally, run the command sqlx migrate run to apply the migrations to the database. To verify that the migration was successful, open pgAdmin in your browser and use the credentials from the .env file to connect to the Postgres server. Then, navigate to the tables section, where you should see the feedbacks table.

Create the SQLX Database Model

Next, let's create the SQLx database model. This model needs to match the structure of the feedbacks table we created earlier, as SQLx performs compile-time checks to ensure that the model's fields and types align with the corresponding database table.

If there are any mismatches, SQLx will raise errors. To set this up, create a model.rs file within the src directory and add the following code:

src/model.rs


use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Feedback {
    pub id: Uuid,
    pub name: String,
    pub email: String,
    pub feedback: String,
    pub rating: i32,
    pub status: Option<String>,
     #[serde(rename = "createdAt")]
    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(rename = "updatedAt")]
    pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}

Create the Data Schema

Now, let's define the structs that will handle the serialization and deserialization of data between HTTP requests, responses, and the database. These structs will serve as our data schemas. To create them, add a new file called schema.rs inside the src directory and include the following code:

src/schema.rs


use serde::{Deserialize, Serialize};

#[derive(Deserialize, Debug, Default)]
pub struct FilterOptions {
    pub page: Option<usize>,
    pub limit: Option<usize>,
}

#[derive(Deserialize, Debug)]
pub struct ParamOptions {
    pub id: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct CreateFeedbackSchema {
    pub name: String,
    pub email: String,
    pub feedback: String,
    pub rating: i32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateFeedbackSchema {
    pub name: Option<String>,
    pub email: Option<String>,
    pub feedback: Option<String>,
    pub rating: Option<i32>,
    pub status: Option<String>,
}

Create the CRUD API Handlers

With our database model and data schemas in place, we can create the Axum route handlers responsible for processing HTTP requests. To get started, create a handler.rs file and add the following imports:

src/handler.rs


use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde_json::json;

use crate::{
    model::Feedback,
    schema::{CreateFeedbackSchema, FilterOptions, UpdateFeedbackSchema},
    AppState,
};

Fetch All Records

Let's start with our first route handler, feedback_list_handler. This handler will retrieve a paginated list of feedback items from the database and return them as a JSON response. In the src/handler.rs file, add the following code:

src/handler.rs


pub async fn feedback_list_handler(
    opts: Option<Query<FilterOptions>>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    // Unwrapping opts or using default if None
    let Query(opts) = opts.unwrap_or_default();

    let limit = opts.limit.unwrap_or(10) as i64;
    let offset = ((opts.page.unwrap_or(1) - 1) * limit as usize) as i64;

    let query_result = sqlx::query_as!(
        Feedback,
        "SELECT * FROM feedbacks ORDER BY id LIMIT $1 OFFSET $2",
        limit,
        offset
    )
    .fetch_all(&data.db)
    .await;

    match query_result {
        Ok(feedbacks) => {
            let json_response = serde_json::json!({
                "status": "success",
                "results": feedbacks.len(),
                "feedbacks": feedbacks
            });
            Ok(Json(json_response))
        }
        Err(_) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": "Something went wrong while fetching feedbacks",
            });
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
        }
    }
}

Create a Record

Next, we'll implement the CREATE operation of our CRUD functionality. We'll name this handler create_feedback_handler, and as the name suggests, it will be responsible for inserting new feedback items into the database. After successfully adding a new item, it will return the newly created feedback as a JSON response. Add the following code to the src/handler.rs file:

src/handler.rs


pub async fn create_feedback_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<CreateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(
        Feedback,
        "INSERT INTO feedbacks (name,email,feedback,rating) VALUES ($1, $2, $3, $4) RETURNING *",
        body.name.to_string(),
        body.email.to_string(),
        body.feedback.to_string(),
        body.rating
    )
    .fetch_one(&data.db)
    .await;

    match query_result {
        Ok(feedback) => {
            let feedback_response = json!({"status": "success","data": json!({
                "feedback": feedback
            })});

            return Ok((StatusCode::CREATED, Json(feedback_response)));
        }
        Err(e) => {
            if e.to_string()
                .contains("duplicate key value violates unique constraint")
            {
                let error_response = serde_json::json!({
                    "status": "fail",
                    "message": "This feedback already exists",
                });
                return Err((StatusCode::CONFLICT, Json(error_response)));
            }
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    }
}

Fetch a Record

Let's implement the second READ operation, which will return a single feedback item. We'll name this handler get_feedback_handler, and its role will be to retrieve a specific feedback item from the database and return it as a JSON response. Add the following code to the src/handler.rs file:

src/handler.rs


pub async fn get_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(Feedback, "SELECT * FROM feedbacks WHERE id = $1", id)
        .fetch_one(&data.db)
        .await;

    match query_result {
        Ok(feedback) => {
            let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "feedback": feedback
            })});

            return Ok(Json(feedback_response));
        }
        Err(_) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
    }
}

Edit a Record

Next, we'll implement the UPDATE operation in our CRUD functionality. We'll create a handler named edit_feedback_handler, which will query the database, update the matching record, and return the updated record as a JSON response. Add the following code to the src/handler.rs file:

src/handler.rs


pub async fn edit_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
    Json(body): Json<UpdateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(Feedback, "SELECT * FROM feedbacks WHERE id = $1", id)
        .fetch_one(&data.db)
        .await;

    if query_result.is_err() {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Feedback with ID: {} not found", id)
        });
        return Err((StatusCode::NOT_FOUND, Json(error_response)));
    }

    let now = chrono::Utc::now();
    let feedback = query_result.unwrap();

    let query_result = sqlx::query_as!(
        Feedback,
        "UPDATE feedbacks SET name = $1, email = $2, feedback = $3, rating = $4, updated_at = $5 WHERE id = $6 RETURNING *",
        body.name.to_owned().unwrap_or(feedback.name),
        body.email.to_owned().unwrap_or(feedback.email),
        body.feedback.to_owned().unwrap_or(feedback.feedback),
        body.rating.unwrap_or(feedback.rating),
        now,
        id
    )
    .fetch_one(&data.db)
    .await
    ;

    match query_result {
        Ok(feedback) => {
            let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "feedback": feedback
            })});

            return Ok(Json(feedback_response));
        }
        Err(err) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", err)})),
            ));
        }
    }
}

Delete a Record

Finally, let's implement the DELETE operation, which is the last piece of our CRUD functionality. This handler will simply delete an item from the database and return no content upon successful completion. Add the following code to the src/handler.rs file:

src/handler.rs


pub async fn delete_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let rows_affected = sqlx::query!("DELETE FROM feedbacks  WHERE id = $1", id)
        .execute(&data.db)
        .await
        .unwrap()
        .rows_affected();

    if rows_affected == 0 {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Feedback with ID: {} not found", id)
        });
        return Err((StatusCode::NOT_FOUND, Json(error_response)));
    }

    Ok(StatusCode::NO_CONTENT)
}

Complete API Handlers

Below is the complete code for the API handlers. You can copy and paste it into your src/handler.rs file if you missed a particular step that is causing your code not to function as intended.

src/handler.rs


use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde_json::json;

use crate::{
    model::Feedback,
    schema::{CreateFeedbackSchema, FilterOptions, UpdateFeedbackSchema},
    AppState,
};

pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Feedback CRUD API with Rust, SQLX, Postgres,and Axum";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

pub async fn feedback_list_handler(
    opts: Option<Query<FilterOptions>>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    // Unwrapping opts or using default if None
    let Query(opts) = opts.unwrap_or_default();

    let limit = opts.limit.unwrap_or(10) as i64;
    let offset = ((opts.page.unwrap_or(1) - 1) * limit as usize) as i64;

    let query_result = sqlx::query_as!(
        Feedback,
        "SELECT * FROM feedbacks ORDER BY id LIMIT $1 OFFSET $2",
        limit,
        offset
    )
    .fetch_all(&data.db)
    .await;

    match query_result {
        Ok(feedbacks) => {
            let json_response = serde_json::json!({
                "status": "success",
                "results": feedbacks.len(),
                "feedbacks": feedbacks
            });
            Ok(Json(json_response))
        }
        Err(_) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": "Something went wrong while fetching feedbacks",
            });
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
        }
    }
}

pub async fn create_feedback_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<CreateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(
        Feedback,
        "INSERT INTO feedbacks (name,email,feedback,rating) VALUES ($1, $2, $3, $4) RETURNING *",
        body.name.to_string(),
        body.email.to_string(),
        body.feedback.to_string(),
        body.rating
    )
    .fetch_one(&data.db)
    .await;

    match query_result {
        Ok(feedback) => {
            let feedback_response = json!({"status": "success","data": json!({
                "feedback": feedback
            })});

            return Ok((StatusCode::CREATED, Json(feedback_response)));
        }
        Err(e) => {
            if e.to_string()
                .contains("duplicate key value violates unique constraint")
            {
                let error_response = serde_json::json!({
                    "status": "fail",
                    "message": "This feedback already exists",
                });
                return Err((StatusCode::CONFLICT, Json(error_response)));
            }
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    }
}

pub async fn get_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(Feedback, "SELECT * FROM feedbacks WHERE id = $1", id)
        .fetch_one(&data.db)
        .await;

    match query_result {
        Ok(feedback) => {
            let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "feedback": feedback
            })});

            return Ok(Json(feedback_response));
        }
        Err(_) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
    }
}

pub async fn edit_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
    Json(body): Json<UpdateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let query_result = sqlx::query_as!(Feedback, "SELECT * FROM feedbacks WHERE id = $1", id)
        .fetch_one(&data.db)
        .await;

    if query_result.is_err() {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Feedback with ID: {} not found", id)
        });
        return Err((StatusCode::NOT_FOUND, Json(error_response)));
    }

    let now = chrono::Utc::now();
    let feedback = query_result.unwrap();

    let query_result = sqlx::query_as!(
        Feedback,
        "UPDATE feedbacks SET name = $1, email = $2, feedback = $3, rating = $4, updated_at = $5 WHERE id = $6 RETURNING *",
        body.name.to_owned().unwrap_or(feedback.name),
        body.email.to_owned().unwrap_or(feedback.email),
        body.feedback.to_owned().unwrap_or(feedback.feedback),
        body.rating.unwrap_or(feedback.rating),
        now,
        id
    )
    .fetch_one(&data.db)
    .await
    ;

    match query_result {
        Ok(feedback) => {
            let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
                "feedback": feedback
            })});

            return Ok(Json(feedback_response));
        }
        Err(err) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", err)})),
            ));
        }
    }
}

pub async fn delete_feedback_handler(
    Path(id): Path<uuid::Uuid>,
    State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let rows_affected = sqlx::query!("DELETE FROM feedbacks  WHERE id = $1", id)
        .execute(&data.db)
        .await
        .unwrap()
        .rows_affected();

    if rows_affected == 0 {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Feedback with ID: {} not found", id)
        });
        return Err((StatusCode::NOT_FOUND, Json(error_response)));
    }

    Ok(StatusCode::NO_CONTENT)
}

Create the API Routes

With the route handlers defined, let's set up the routes to invoke them. In the src directory, create a route.rs file and add the following code:

src/route.rs


use std::sync::Arc;

use axum::{
    routing::{get, post},
    Router,
};

use crate::{
    handler::{
        create_feedback_handler, delete_feedback_handler, edit_feedback_handler, get_feedback_handler,
        health_checker_handler, feedback_list_handler,
    },
    AppState,
};

pub fn create_router(app_state: Arc<AppState>) -> Router {
    Router::new()
        .route("/api/healthchecker", get(health_checker_handler))
        .route("/api/feedbacks/", post(create_feedback_handler))
        .route("/api/feedbacks", get(feedback_list_handler))
        .route(
            "/api/feedbacks/:id",
            get(get_feedback_handler)
                .patch(edit_feedback_handler)
                .delete(delete_feedback_handler),
        )
        .with_state(app_state)
}

Set Up CORS and Register the API Routes

To complete this tutorial, we need to register the routes on the server and configure CORS. Open the src/main.rs file and replace its content with the code provided below:

src/main.rs


mod handler;
mod model;
mod route;
mod schema;

use std::sync::Arc;

use axum::http::{
    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
    HeaderValue, Method,
};
use dotenv::dotenv;
use route::create_router;
use tower_http::cors::CorsLayer;

use sqlx::{postgres::PgPoolOptions, Pool, Postgres};

pub struct AppState {
    db: Pool<Postgres>,
}

#[tokio::main]
async fn main() {
    dotenv().ok();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = match PgPoolOptions::new()
        .max_connections(10)
        .connect(&database_url)
        .await
    {
        Ok(pool) => {
            println!("✅Connection to the database is successful!");
            pool
        }
        Err(err) => {
            println!("🔥 Failed to connect to the database: {:?}", err);
            std::process::exit(1);
        }
    };

    let cors = CorsLayer::new()
        .allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
        .allow_credentials(true)
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);

    let app = create_router(Arc::new(AppState { db: pool.clone() })).layer(cors);

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

And we are done! We can now start making requests to the API. Ensure that the Postgres Docker container is still running, and then start the Axum development server with cargo run if you haven’t done so already.

Let's test the CREATE CRUD functionality to ensure everything was implemented correctly. To access the Postman collection I used for testing the API endpoints, you can download the project from its GitHub repository and import the Feedback App.postman_collection.json file into Postman.

Next, add the following JSON data to the request body of your API client, and then send a POST request to the http://localhost:8000/api/feedbacks/ URL.


{
    "name": "Test",
    "email": "test@gmail.com",
    "feedback": "I really like your blog and I want to say thanks",
    "rating": 3
}

You should receive a 201 response from the API, indicating that the new feedback item has been successfully added to the database. Additionally, you should see the added item in the JSON response. You can test the validation rules by omitting some fields from the JSON data or by using incorrect data types for the fields.

Conclusion

And that’s a wrap! In this comprehensive guide, you learned how to build a CRUD API in Rust using the Axum framework, SQLx, and PostgreSQL. I hope you found this article both helpful and enjoyable. If you have any questions or feedback, feel free to leave a comment below.