In this article, we’ll build a CRUD (Create, Read, Update, Delete) API in Rust using the Axum web framework and MySQL as the database. Over the past few years, Rust has rapidly gained popularity as a high-performance language, making it an excellent choice for building APIs.

I’ve built APIs in various languages, which I’ve documented on this blog, but when it comes to personal projects, I often lean toward Rust. Its unique combination of speed, safety, and seamless concurrency makes it an ideal choice for developing robust APIs.

Previously, we created this CRUD API with MongoDB and PostgreSQL databases, but in this article, we’ll replicate the same functionality using MySQL as our database.

Here’s a quick overview of the steps we’ll follow: first, we’ll set up a MySQL database with Docker, then use SQLx-CLI for schema migrations, followed by creating the CRUD API controllers and endpoints. Finally, we’ll configure CORS on the server to allow cross-origin requests.

By the end of this tutorial, you’ll have a fully functional CRUD API built with Rust.

Related Articles

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

Test the CRUD Endpoints on Your Computer

Before diving into the API implementation, you can download the project’s source code and run it on your machine. This will allow you to test the CRUD endpoints and understand what we’ll be building in this tutorial. To run the project without writing any code, follow the steps below:

  • You can download or clone the project from its GitHub repository at https://github.com/wpcodevo/rust-axum-mysql-crud-api and open the source code in your preferred code editor or IDE.
  • Launch a MySQL server instance in Docker by running the command docker-compose up -d. If Docker is not installed on your machine, you can download and install it from their official website.
  • After the MySQL database is up and running, apply the database migrations by executing sqlx migrate run. If you haven’t installed the SQLx-CLI yet, you can do so with the following command: cargo install sqlx-cli.
  • Once the MySQL database server runs, you can launch the Axum HTTP server by executing the cargo run command. This will install any required dependencies and start the Axum development server.

Set Up the Rust API

To set up the Rust project, follow the steps below:

  • Create a folder:
    Go to your desktop or any location where you’d like to store the source code. Create a new folder named rust-axum-mysql-crud-api
  • Initialize the project:
    Open the newly created folder in your terminal and run the command cargo init to initialize it as a Rust binary project.
  • Open the project in a code editor:
    Once the project is initialized, open the folder in your preferred code editor or integrated development environment (IDE).
  • Install required crates:
    Open the integrated terminal in your code editor and run the following commands to install the required dependencies.

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 mysql chrono uuid"

If you’re following this tutorial in the future and encounter issues with newer versions of any crates, you can revert to the versions specified in the Cargo.toml file below.

Cargo.toml


[package]
name = "rust-axum-mysql-crud-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7.7"
chrono = { version = "0.4.38", features = ["serde"] }
dotenv = "0.15.0"
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
sqlx = { version = "0.8.2", features = ["runtime-async-std-native-tls", "mysql", "chrono", "uuid"] }
tokio = { version = "1.40.0", features = ["full"] }
tower-http = { version = "0.6.1", features = ["cors"] }
uuid = { version = "1.10.0", features = ["serde", "v4"] }

  • axum – A web framework for building APIs and handling HTTP requests.
  • chrono – Date and time library for Rust
  • dotenv – Loads environment variables from a .env file.
  • serde – A framework for serializing and deserializing Rust data structures
  • sqlx – An SQL toolkit for Rust
  • tokio – A runtime for writing asynchronous applications in Rust
  • tower-http – Has a lot of middleware that are useful when building HTTP servers and clients

Launch MySQL Instance in Docker

Next, let’s launch the MySQL server instance using Docker. If you already have MySQL running locally, you can skip this section, but ensure that you update the connection URL in your .env file.

To proceed, create a docker-compose.yml file in the root directory and add the following Docker configurations:

docker-compose.yml


services:
  mysql:
    image: mysql:latest
    container_name: mysql
    env_file:
      - ./.env
    ports:
      - '6500:3306'
    volumes:
      - mysqlDB:/var/lib/mysql
  phpMyAdmin:
    image: phpmyadmin
    restart: always
    container_name: phpMyAdmin
    env_file:
      - ./.env
    environment:
      - PMA_HOST=mysql
    ports:
      - '8080:80'
    depends_on:
      - mysql
volumes:
  mysqlDB:

We have also defined the phpMyAdmin service, which provides a graphical interface to manage the data stored in the MySQL database. Additionally, we used the env_file field to load the necessary credentials from a .env file. To proceed, create a file named .env in the root directory and include the following environment variables.

.env


MYSQL_DATABASE=rust_sqlx
MYSQL_USER=admin
MYSQL_PASSWORD=password123
MYSQL_ROOT_PASSWORD=password123

# Don't worry about the placeholders. Rust supports this feature.
DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@localhost:6500/${MYSQL_DATABASE}

With the environment variables set, launch both the MySQL and PHPMyAdmin server by running the command docker-compose up -d.

Perform Database Migrations with SQLx

With the MySQL database now up and running, let’s move on to creating the necessary tables. We’ll handle this entirely through code using the SQLx-CLI toolkit, which allows us to perform migrations on the database schema. To start, make sure you have the SQLx-CLI installed. If not, you can install it by running the command: cargo install sqlx-cli.

Once SQLx-CLI is installed, run the command sqlx migrate add -r init to generate both the ‘up’ and ‘down’ migration scripts inside a migrations folder.

Open the ‘up’ migration script and include the following SQL statements, which will create the feedbacks table in the database:

migrations/20241011041603_init.up.sql


-- Add up migration script here

CREATE TABLE feedbacks (
    id CHAR(36) PRIMARY KEY NOT NULL,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    feedback VARCHAR(500) NOT NULL UNIQUE,
    rating FLOAT CHECK (rating >= 1 AND rating <= 5) NOT NULL,
    status VARCHAR(50) DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

The code above creates the feedbacks table. To ensure we can undo this action if needed, we'll add the necessary SQL statements in the 'down' script. Open the 'down' migration script and include the following code:

migrations/20241011041603_init.down.sql


-- Add down migration script here
DROP TABLE IF EXISTS feedbacks;

To apply the 'up' migration script and create the feedbacks table in the database, run the command sqlx migrate run. Once the migration completes successfully, you can verify that the table was created by inspecting the database in phpMyAdmin. Log into phpMyAdmin using the credentials from the .env file.

If you need to revert the changes made by the 'up' script, you can run the command sqlx migrate revert, which will drop the feedbacks table.

Create the Database Model

With the feedbacks table now created in the database, the next step is to define its struct representation in Rust. To do this, create a model.rs file inside the src directory and add the following code:

src/model.rs


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

#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Feedback {
    pub id: String,
    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 Request Schema Structs

Next, we’ll create a few structs to represent the request body our server will handle during the user login and sign-up processes. Navigate to the src directory and create a new file named schema.rs. Then, add the following code to it:

src/schema.rs


use serde::{Deserialize, Serialize};

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

#[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>,
}

Perform the CRUD Operations

Now it's time to implement the CRUD operations. In this API, we'll create five endpoints, each with its corresponding Axum handler.

Fetch All Records

Let's begin with the first route handler, named feedback_list_handler. This handler will be triggered by a GET request to retrieve feedback items from the database. To prevent sending large payloads in the JSON response—especially if there are millions of records—we will implement a pagination feature that, by default, returns only the first 10 records. Below is the implementation of this route handler.

src/handlers.rs


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

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

    let query_result = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks ORDER BY id LIMIT ? OFFSET ?"#,
        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)))
        }
    }
}

Insert a Record

Next, let’s implement the CREATE operation for our CRUD functionality. This involves retrieving the request body and inserting the data into the database. Upon successful insertion, we will query the database to fetch the newly created record and return it in the JSON response.

If the insertion encounters an error, we will specifically check for duplicate entry issues, given that we have defined a unique index on the feedback field. In the event of a duplicate entry, we will provide a well-formatted error message to inform the user that the feedback already exists. Below is the implementation of the route handler:

src/handlers.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 feedback_id = uuid::Uuid::new_v4().to_string();
    let query_result = sqlx::query(
        r#"INSERT INTO feedbacks (id,name,email,feedback,rating) VALUES (?, ?, ?, ?, ?)"#,
    )
    .bind(feedback_id.clone())
    .bind(body.name.to_string())
    .bind(body.email.to_string())
    .bind(body.feedback.to_string())
    .bind(body.rating)
    .execute(&data.db)
    .await
    .map_err(|err: sqlx::Error| err.to_string());

    if let Err(err) = query_result {
        if err.contains("Duplicate entry") {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": "Feedback already exists",
            });
            return Err((StatusCode::CONFLICT, Json(error_response)));
        }

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

    let feedback = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        feedback_id
    )
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

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

    Ok(Json(feedback_response))
}

Fetch a Record

Now, let’s implement the second READ operation, which will retrieve a single record. When a GET request is made to fetch a specific feedback entry, we will extract the ID parameter from the request URL. Using this ID, along with the sqlx::query_as! macro, we will query the database.

If a matching record is found, we will return it in the JSON response; if not, we will respond with a 404 error, indicating that no record in the database corresponds to the provided ID. Below is the implementation of the route handler:

src/handlers.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 = ?",
        id.to_string()
    )
    .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(sqlx::Error::RowNotFound) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
        Err(e) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    };
}

Edit a Record

Next, we’ll implement the UPDATE operation for our CRUD functionality. This handler will be triggered by a PATCH request to the server. Upon receiving the request, the server will first query the database to check if the feedback item to be updated exists.

If it does, the server will proceed to update the feedback data based on the fields provided in the request body. Once the update is successful, a copy of the updated data will be returned in the JSON response.

src/handlers.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,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        id.to_string()
    )
    .fetch_one(&data.db)
    .await;

    let feedback = match query_result {
        Ok(feedback) => feedback,
        Err(sqlx::Error::RowNotFound) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
        Err(e) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    };

    let now = chrono::Utc::now();

    let update_query = sqlx::query(
        r#"UPDATE feedbacks SET name = ?, email = ?, feedback = ?, rating = ?, updated_at = ? WHERE id = ?"#,
    ).bind(body.name.to_owned().unwrap_or(feedback.name))
    .bind(body.email.to_owned().unwrap_or(feedback.email))
    .bind(body.feedback.to_owned().unwrap_or(feedback.feedback))
    .bind(body.rating.unwrap_or(feedback.rating))
    .bind(now)
    .bind(id.to_string())
    .execute(&data.db)
    .await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

    if update_query.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)));
    }

    let updated_feedback = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        id.to_string()
    )
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

    let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
        "feedback": updated_feedback
    })});

    Ok(Json(feedback_response))
}

Delete a Record

Finally, we’ll implement the last CRUD operation: DELETE. This process is straightforward. We will extract the ID of the feedback item to be deleted from the request URL. Using this ID, we’ll employ the sqlx::query! macro to execute a query that removes the corresponding record from the database.

If no record is deleted, it indicates that no feedback with the specified ID exists, and we will return a 404 error. If the deletion is successful, we will respond with a 204 status code to indicate success.

src/handlers.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 query = sqlx::query!("DELETE FROM feedbacks  WHERE id = ?", id.to_string())
        .execute(&data.db)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            )
        })?;

    if query.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 CRUD Code

Below is the complete code for the CRUD functionality:

src/handlers.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>)> {
    let Query(opts) = opts.unwrap_or_default();

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

    let query_result = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks ORDER BY id LIMIT ? OFFSET ?"#,
        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 feedback_id = uuid::Uuid::new_v4().to_string();
    let query_result = sqlx::query(
        r#"INSERT INTO feedbacks (id,name,email,feedback,rating) VALUES (?, ?, ?, ?, ?)"#,
    )
    .bind(feedback_id.clone())
    .bind(body.name.to_string())
    .bind(body.email.to_string())
    .bind(body.feedback.to_string())
    .bind(body.rating)
    .execute(&data.db)
    .await
    .map_err(|err: sqlx::Error| err.to_string());

    if let Err(err) = query_result {
        if err.contains("Duplicate entry") {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": "Feedback already exists",
            });
            return Err((StatusCode::CONFLICT, Json(error_response)));
        }

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

    let feedback = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        feedback_id
    )
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

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

    Ok(Json(feedback_response))
}

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 = ?",
        id.to_string()
    )
    .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(sqlx::Error::RowNotFound) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
        Err(e) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    };
}

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,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        id.to_string()
    )
    .fetch_one(&data.db)
    .await;

    let feedback = match query_result {
        Ok(feedback) => feedback,
        Err(sqlx::Error::RowNotFound) => {
            let error_response = serde_json::json!({
                "status": "fail",
                "message": format!("Feedback with ID: {} not found", id)
            });
            return Err((StatusCode::NOT_FOUND, Json(error_response)));
        }
        Err(e) => {
            return Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            ));
        }
    };

    let now = chrono::Utc::now();

    let update_query = sqlx::query(
        r#"UPDATE feedbacks SET name = ?, email = ?, feedback = ?, rating = ?, updated_at = ? WHERE id = ?"#,
    ).bind(body.name.to_owned().unwrap_or(feedback.name))
    .bind(body.email.to_owned().unwrap_or(feedback.email))
    .bind(body.feedback.to_owned().unwrap_or(feedback.feedback))
    .bind(body.rating.unwrap_or(feedback.rating))
    .bind(now)
    .bind(id.to_string())
    .execute(&data.db)
    .await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

    if update_query.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)));
    }

    let updated_feedback = sqlx::query_as!(
        Feedback,
        r#"SELECT * FROM feedbacks WHERE id = ?"#,
        id.to_string()
    )
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"status": "error","message": format!("{:?}", e)})),
        )
    })?;

    let feedback_response = serde_json::json!({"status": "success","data": serde_json::json!({
        "feedback": updated_feedback
    })});

    Ok(Json(feedback_response))
}

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 query = sqlx::query!("DELETE FROM feedbacks  WHERE id = ?", id.to_string())
        .execute(&data.db)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"status": "error","message": format!("{:?}", e)})),
            )
        })?;

    if query.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)
}

Set Up the API Routes

Next, we’ll set up the routes to invoke the route handlers. We will create a function that returns an instance of the Axum router, appending each CRUD route along with its corresponding handler. To accomplish this, create a file named route.rs in the src directory and include the following code:

src/route.rs


use std::sync::Arc;

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

use crate::{
    handlers::{
        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)
}

Register the Axum Router and Set Up CORS

Next, let's integrate everything we've built so far into the main.rs file. We will import the necessary modules, establish a connection to the MySQL server, and configure CORS to enable cross-origin requests. Open the main.rs file and add the following code:

src/main.rs


mod handlers;
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::mysql::{MySqlPool, MySqlPoolOptions};

pub struct AppState {
    db: MySqlPool,
}

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

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = match MySqlPoolOptions::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()
}

Conclusion

Congratulations on making it this far! In this comprehensive guide, we’ve built a CRUD API using Rust with the Axum web framework and MySQL. I hope you found this tutorial informative and gained new insights along the way. If you have any questions or feedback, feel free to share them in the comment section below.