If you’ve been following my Rust series, you may have noticed that we previously built a CRUD API using Axum as the HTTP framework and MongoDB as the database. However, the MongoDB crate has undergone some upgrades, and certain APIs have been deprecated. Notably, the bson-chrono feature, which was once part of the MongoDB crate, is now included as a feature in the bson package.

In this article, I will guide you through the process of building a CRUD API—specifically, an API for a feedback application—using Axum as our HTTP framework and MongoDB as our data storage solution.

I understand that when creating APIs in Rust, many developers lean towards SQL-based databases like PostgreSQL. However, MongoDB is also a solid choice, especially for applications that require flexible schema design.

We will use the MongoDB Rust driver to directly interact with the MongoDB server, enabling fast data access without relying on an ODM (Object-Document Mapper) like Mongoose in Node.js.

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 with Axum and MongoDB in Rust

Run the Axum MongoDB API on Your Machine

To set up and interact with the various endpoints of the feedback API on your local machine, follow the steps listed below:

  • Download or clone the project from its GitHub repository at https://github.com/wpcodevo/feedback-axum-mongodb and open the source code in your preferred code editor.
  • Launch a MongoDB server with 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 MongoDB database server is up and running, 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 Project

Let’s start by setting up our Rust project. Navigate to your desired directory and create a new folder named feedback-axum-mongodb (feel free to choose a different name).

Open the folder in VS Code or your preferred IDE. In the terminal, run cargo init to create a new Rust binary project.

After the project is initialized, install the necessary dependencies for building the API by running the following commands in your terminal.


cargo add axum
cargo add tower-http -F 'cors'
cargo add mongodb
cargo add bson -F 'chrono-0_4'
cargo add futures --features async-await --no-default-features
cargo add serde -F derive
cargo add serde_json
cargo add thiserror
cargo add chrono -F serde
cargo add tokio -F full
cargo add dotenv

Launch MongoDB in Docker

We’ll start by launching MongoDB using Docker. If you already have a MongoDB server running locally, feel free to skip this section, but ensure that you add the connection URL to your .env file.

With that said, create a docker-compose.yml file and add the following code:

docker-compose.yml


services:
  mongo:
    image: mongo:latest
    container_name: mongo
    env_file:
      - ./.env
    volumes:
      - mongo:/data/db
    ports:
      - '6000:27017'

volumes:
  mongo:

Next, create a .env file in the root directory of your project and add the following environment variables:

.env


MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=rust_mongodb

MONGODB_NOTE_COLLECTION=feedbacks
DATABASE_URL=mongodb://admin:password123@localhost:6000/rust_mongodb?authSource=admin

With the environment variables set, run the command docker-compose up -d to start the MongoDB instance in a Docker container.

Define the Feedback MongoDB Model

Now that we have a running MongoDB database, let’s proceed to create the database model for the feedback application.

To do this, navigate to your src directory and create a new file named model.rs. Then, add the following code to the file:

src/model.rs


use chrono::prelude::*;
use mongodb::bson::{self, oid::ObjectId};
use serde::{Deserialize, Serialize};

#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FeedbackModel {
    #[serde(rename = "_id")]
    pub id: ObjectId,
    pub name: String,
    pub email: String,
    pub feedback: String,
    pub rating: f32,
    pub status: String,
    #[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
    pub createdAt: DateTime<Utc>,
    #[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
    pub updatedAt: DateTime<Utc>,
}

Create the HTTP Request Schemas

In API development, it’s always recommended to validate incoming request data to filter out unnecessary fields and ensure the data aligns with the server’s defined schema.

For this simple feedback API, we’ll skip detailed validation but will define structs to ensure the request body and parameters match the expected format. To do this, create a src/schema.rs file and add 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: f32,
}

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

Create the HTTP Response Schemas

Next, we need to define the structure of the responses we’ll send to the user. To do this, create a src/response.rs file and add the following code:

src/response.rs


use chrono::{DateTime, Utc};
use serde::Serialize;

#[derive(Serialize)]
pub struct GenericResponse {
    pub status: String,
    pub message: String,
}

#[allow(non_snake_case)]
#[derive(Serialize, Debug)]
pub struct FeedbackResponse {
    pub id: String,
    pub name: String,
    pub email: String,
    pub feedback: String,
    pub rating: f32,
    pub status: String,
    pub createdAt: DateTime<Utc>,
    pub updatedAt: DateTime<Utc>,
}

#[derive(Serialize, Debug)]
pub struct FeedbackData {
    pub feedback: FeedbackResponse,
}

#[derive(Serialize, Debug)]
pub struct SingleFeedbackResponse {
    pub status: &'static str,
    pub data: FeedbackData,
}

#[derive(Serialize, Debug)]
pub struct FeedbackListResponse {
    pub status: &'static str,
    pub results: usize,
    pub feedbacks: Vec<FeedbackResponse>,
}

Error Handling with MongoDB and Rust

When working with MongoDB, various errors can occur, and it’s important to handle them gracefully by returning clear, well-formatted messages to users. This approach hides sensitive details that could be exploited by attackers while providing users with understandable explanations of the issue.

To manage these errors effectively, create a src/error.rs file and add the following code:

src/error.rs


use axum::{http::StatusCode, Json};
use serde::Serialize;

#[derive(thiserror::Error, Debug)]
pub enum MyError {
    #[error("MongoDB error")]
    MongoError(#[from] mongodb::error::Error),
    #[error("duplicate key error: {0}")]
    MongoErrorKind(mongodb::error::ErrorKind),
    #[error("duplicate key error: {0}")]
    MongoDuplicateError(mongodb::error::Error),
    #[error("error during mongodb query: {0}")]
    MongoQueryError(mongodb::error::Error),
    #[error("error serializing BSON")]
    MongoSerializeBsonError(#[from] mongodb::bson::ser::Error),
    #[error("validation error")]
    MongoDataError(#[from] mongodb::bson::document::ValueAccessError),
    #[error("invalid ID: {0}")]
    InvalidIDError(String),
    #[error("Document with ID: {0} not found")]
    NotFoundError(String),
}

#[derive(Serialize)]
struct ErrorResponse {
    status: &'static str,
    message: String,
}

impl Into<(axum::http::StatusCode, Json<serde_json::Value>)> for MyError {
    fn into(self) -> (axum::http::StatusCode, Json<serde_json::Value>) {
        let (status, error_response) = match self {
            MyError::MongoErrorKind(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                ErrorResponse {
                    status: "error",
                    message: format!("MongoDB error kind: {}", e),
                },
            ),
            MyError::MongoDuplicateError(_) => (
                StatusCode::CONFLICT,
                ErrorResponse {
                    status: "fail",
                    message: "Feedback with that text already exists".to_string(),
                },
            ),
            MyError::InvalidIDError(id) => (
                StatusCode::BAD_REQUEST,
                ErrorResponse {
                    status: "fail",
                    message: format!("invalid ID: {}", id),
                },
            ),
            MyError::NotFoundError(id) => (
                StatusCode::NOT_FOUND,
                ErrorResponse {
                    status: "fail",
                    message: format!("Feedback with ID: {} not found", id),
                },
            ),
            MyError::MongoError(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                ErrorResponse {
                    status: "error",
                    message: format!("MongoDB error: {}", e),
                },
            ),
            MyError::MongoQueryError(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                ErrorResponse {
                    status: "error",
                    message: format!("MongoDB error: {}", e),
                },
            ),
            MyError::MongoSerializeBsonError(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                ErrorResponse {
                    status: "error",
                    message: format!("MongoDB error: {}", e),
                },
            ),
            MyError::MongoDataError(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                ErrorResponse {
                    status: "error",
                    message: format!("MongoDB error: {}", e),
                },
            ),
        };
        (status, Json(serde_json::to_value(error_response).unwrap()))
    }
}

impl From<MyError> for (StatusCode, ErrorResponse) {
    fn from(err: MyError) -> (StatusCode, ErrorResponse) {
        err.into()
    }
}

Connect the Axum Server to MongoDB

At this stage, we’ve set up error handling and created our MongoDB model. The next step is to connect our Axum server to the running MongoDB instance.

To keep things organized, we’ll place all database-related code in a db.rs file. Navigate to the src directory and create a new file named db.rs. Add the following code to it:

src/db.rs


use crate::error::MyError;
use crate::model::FeedbackModel;
use mongodb::bson::Document;
use mongodb::{ options::ClientOptions, Client, Collection};

#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn init() -> Result<Self> {
        let mongodb_uri = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set.");
        let database_name =
            std::env::var("MONGO_INITDB_DATABASE").expect("MONGO_INITDB_DATABASE must be set.");
        let collection_name =
            std::env::var("MONGODB_NOTE_COLLECTION").expect("MONGODB_NOTE_COLLECTION must be set.");

        let mut client_options = ClientOptions::parse(mongodb_uri).await?;
        client_options.app_name = Some(database_name.to_string());

        let client = Client::with_options(client_options)?;
        let database = client.database(database_name.as_str());

        let feedback_collection = database.collection(collection_name.as_str());
        let collection = database.collection::<Document>(collection_name.as_str());

        println!("✅ Database connected successfully");

        Ok(Self {
            feedback_collection,
            collection,
        })
    }
}

We’ve defined an init function to handle the connection to the MongoDB instance and to create the feedback collection in the database.

Now, we need to call the init function within the main function to establish the connection to the MongoDB server.

src/main.rs


mod db;
mod error;
mod model;

use std::sync::Arc;

use axum::Router;
use db::DB;
use dotenv::dotenv;
use error::MyError;

pub struct AppState {
    db: DB,
}

#[tokio::main]
async fn main() -> Result<(), MyError> {
    dotenv().ok();

    let db = DB::init().await?;

    let app = Router::new().with_state(Arc::new(AppState{db: db.clone()}));

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

    Ok(())
}

Transform MongoDB Document to Rust Struct

When data is retrieved from MongoDB, the MongoDB driver returns it in BSON format. To make the data usable in our application, we must convert it into a Rust type. The simplest approach is to deserialize the BSON into a Rust struct.

To do this, create a src/db.rs file and add the following code:

src/db.rs


use bson::Document;
use mongodb::Collection;

use crate::{error::MyError, model::FeedbackModel, response::FeedbackResponse};


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    fn doc_to_feedback(&self, feedback: &FeedbackModel) -> Result<FeedbackResponse> {
        let feedback_response = FeedbackResponse {
            id: feedback.id.to_hex(),
            name: feedback.name.to_owned(),
            email: feedback.email.to_owned(),
            feedback: feedback.feedback.to_owned(),
            rating: feedback.rating.to_owned(),
            status: feedback.status.to_owned(),
            createdAt: feedback.createdAt,
            updatedAt: feedback.updatedAt,
        };

        Ok(feedback_response)
    }
}

Perform MongoDB CRUD Operations

Now that our application is connected to MongoDB, it’s time to perform CRUD operations on the database. To achieve this, we’ll create higher-level functions that utilize the lower-level CRUD operations provided by the MongoDB driver.

Fetch All Documents

Let’s start with the READ operation, which allows us to retrieve multiple documents from MongoDB. For this, we use the .find() method available on the collection. This method returns a cursor, which we iterate through to access individual documents. Below is the code demonstrating this:

src/db.rs


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn fetch_feedbacks(&self, limit: i64, page: i64) -> Result<FeedbackListResponse> {
        let mut cursor = self
            .feedback_collection
            .find(doc! {})
            .limit(limit)
            .skip(u64::try_from((page - 1) * limit).unwrap())
            .await
            .map_err(MongoQueryError)?;

        let mut json_result: Vec<FeedbackResponse> = Vec::new();
        while let Some(doc) = cursor.next().await {
            json_result.push(self.doc_to_feedback(&doc.unwrap())?);
        }

        Ok(FeedbackListResponse {
            status: "success",
            results: json_result.len(),
            feedbacks: json_result,
        })
    }
}

Additionally, we use the .limit() and .skip() methods to ensure that we don’t return all the documents in the collection at once.

Insert a Document

Let’s move on to the CREATE operation, which involves inserting a document into the collection. We’ll also add an index to ensure no duplicate feedback is added to the collection. Afterwards, we’ll use the .insert_one() method to add the new document to the collection.

src/db.rs


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn create_feedback(
        &self,
        body: &CreateFeedbackSchema,
    ) -> Result<SingleFeedbackResponse> {
        let status = String::from("pending");

        let document = self.create_feedback_document(body, status)?;

        let options = IndexOptions::builder().unique(true).build();
        let index = IndexModel::builder()
            .keys(doc! {"feedback": 1})
            .options(options)
            .build();

        match self.feedback_collection.create_index(index).await {
            Ok(_) => {}
            Err(e) => return Err(MongoQueryError(e)),
        };

        let insert_result = match self.collection.insert_one(&document).await {
            Ok(result) => result,
            Err(e) => {
                if e.to_string()
                    .contains("E11000 duplicate key error collection")
                {
                    return Err(MongoDuplicateError(e));
                }
                return Err(MongoQueryError(e));
            }
        };

        let new_id = insert_result
            .inserted_id
            .as_object_id()
            .expect("issue with new _id");

        let feedback_doc = match self
            .feedback_collection
            .find_one(doc! {"_id": new_id})
            .await
        {
            Ok(Some(doc)) => doc,
            Ok(None) => return Err(NotFoundError(new_id.to_string())),
            Err(e) => return Err(MongoQueryError(e)),
        };

        Ok(SingleFeedbackResponse {
            status: "success",
            data: FeedbackData {
                feedback: self.doc_to_feedback(&feedback_doc)?,
            },
        })
    }

    fn create_feedback_document(
        &self,
        body: &CreateFeedbackSchema,
        status: String,
    ) -> Result<bson::Document> {
        let serialized_data = bson::to_bson(body).map_err(MongoSerializeBsonError)?;
        let document = serialized_data.as_document().unwrap();

        let datetime = Utc::now();

        let mut doc_with_dates = doc! {
        "createdAt": datetime,
        "updatedAt": datetime,
        "status": status        };
        doc_with_dates.extend(document.clone());

        Ok(doc_with_dates)
    }
}

Fetch a Document

Next, let’s handle the second use case of the READ operation, which involves retrieving a single document from the collection. We will use the .find_one() method, passing in the ID of the document we wish to retrieve.

src/db.rs


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn get_feedback(&self, id: &str) -> Result<SingleFeedbackResponse> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;

        let feedback_doc = self
            .feedback_collection
            .find_one(doc! {"_id":oid })
            .await
            .map_err(MongoQueryError)?;

        match feedback_doc {
            Some(doc) => {
                let feedback = self.doc_to_feedback(&doc)?;
                Ok(SingleFeedbackResponse {
                    status: "success",
                    data: FeedbackData { feedback },
                })
            }
            None => Err(NotFoundError(id.to_string())),
        }
    }
}

Edit a Document

When updating a document, there are two common HTTP methods: PUT and PATCH. PUT is used to update an entire document, replacing it completely, while PATCH allows partial updates to specific fields.

For our API, we’ll use the PATCH approach to provide more flexibility. MongoDB’s Rust driver offers a find_one_and_update() method, which is ideal for updating specific fields in a document. Here’s how you can implement it:

src/db.rs


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn edit_feedback(
        &self,
        id: &str,
        body: &UpdateFeedbackSchema,
    ) -> Result<SingleFeedbackResponse> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;

        let update = doc! {
            "$set": bson::to_document(body).map_err(MongoSerializeBsonError)?,
        };

        if let Some(doc) = self
            .feedback_collection
            .find_one_and_update(doc! {"_id": oid}, update)
            .return_document(ReturnDocument::After)
            .await
            .map_err(MongoQueryError)?
        {
            let feedback = self.doc_to_feedback(&doc)?;
            let feedback_response = SingleFeedbackResponse {
                status: "success",
                data: FeedbackData { feedback },
            };
            Ok(feedback_response)
        } else {
            Err(NotFoundError(id.to_string()))
        }
    }
}

We used the .return_document() method to retrieve the updated document from the collection after the update operation.

Delete a Document

Let’s wrap up by implementing the DELETE operation. First, we need to parse the ID provided in the URL parameter to ensure it’s a valid MongoDB ObjectID. Then, we create a filter and use the .delete_one() method to remove the document that matches the query from the collection.

src/db.rs


#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn delete_feedback(&self, id: &str) -> Result<()> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
        let filter = doc! {"_id": oid };

        let result = self
            .collection
            .delete_one(filter)
            .await
            .map_err(MongoQueryError)?;

        match result.deleted_count {
            0 => Err(NotFoundError(id.to_string())),
            _ => Ok(()),
        }
    }
}

Complete MongoDB CRUD Code

Below is the complete code for the higher-level CRUD functions we implemented in the previous sections.

src/db.rs


use crate::error::MyError;
use crate::response::{
    FeedbackData, FeedbackListResponse, FeedbackResponse, SingleFeedbackResponse,
};
use crate::{
    error::MyError::*, model::FeedbackModel, schema::CreateFeedbackSchema,
    schema::UpdateFeedbackSchema,
};
use chrono::prelude::*;
use futures::StreamExt;
use mongodb::bson::{doc, oid::ObjectId, Document};
use mongodb::options::{IndexOptions, ReturnDocument};
use mongodb::{bson, options::ClientOptions, Client, Collection, IndexModel};
use std::str::FromStr;

#[derive(Clone, Debug)]
pub struct DB {
    pub feedback_collection: Collection<FeedbackModel>,
    pub collection: Collection<Document>,
}

type Result<T> = std::result::Result<T, MyError>;

impl DB {
    pub async fn init() -> Result<Self> {
        let mongodb_uri = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set.");
        let database_name =
            std::env::var("MONGO_INITDB_DATABASE").expect("MONGO_INITDB_DATABASE must be set.");
        let collection_name =
            std::env::var("MONGODB_NOTE_COLLECTION").expect("MONGODB_NOTE_COLLECTION must be set.");

        let mut client_options = ClientOptions::parse(mongodb_uri).await?;
        client_options.app_name = Some(database_name.to_string());

        let client = Client::with_options(client_options)?;
        let database = client.database(database_name.as_str());

        let feedback_collection = database.collection(collection_name.as_str());
        let collection = database.collection::<Document>(collection_name.as_str());

        println!("✅ Database connected successfully");

        Ok(Self {
            feedback_collection,
            collection,
        })
    }

    pub async fn fetch_feedbacks(&self, limit: i64, page: i64) -> Result<FeedbackListResponse> {
        let mut cursor = self
            .feedback_collection
            .find(doc! {})
            .limit(limit)
            .skip(u64::try_from((page - 1) * limit).unwrap())
            .await
            .map_err(MongoQueryError)?;

        let mut json_result: Vec<FeedbackResponse> = Vec::new();
        while let Some(doc) = cursor.next().await {
            json_result.push(self.doc_to_feedback(&doc.unwrap())?);
        }

        Ok(FeedbackListResponse {
            status: "success",
            results: json_result.len(),
            feedbacks: json_result,
        })
    }

    pub async fn create_feedback(
        &self,
        body: &CreateFeedbackSchema,
    ) -> Result<SingleFeedbackResponse> {
        let status = String::from("pending");

        let document = self.create_feedback_document(body, status)?;

        let options = IndexOptions::builder().unique(true).build();
        let index = IndexModel::builder()
            .keys(doc! {"feedback": 1})
            .options(options)
            .build();

        match self.feedback_collection.create_index(index).await {
            Ok(_) => {}
            Err(e) => return Err(MongoQueryError(e)),
        };

        let insert_result = match self.collection.insert_one(&document).await {
            Ok(result) => result,
            Err(e) => {
                if e.to_string()
                    .contains("E11000 duplicate key error collection")
                {
                    return Err(MongoDuplicateError(e));
                }
                return Err(MongoQueryError(e));
            }
        };

        let new_id = insert_result
            .inserted_id
            .as_object_id()
            .expect("issue with new _id");

        let feedback_doc = match self
            .feedback_collection
            .find_one(doc! {"_id": new_id})
            .await
        {
            Ok(Some(doc)) => doc,
            Ok(None) => return Err(NotFoundError(new_id.to_string())),
            Err(e) => return Err(MongoQueryError(e)),
        };

        Ok(SingleFeedbackResponse {
            status: "success",
            data: FeedbackData {
                feedback: self.doc_to_feedback(&feedback_doc)?,
            },
        })
    }

    pub async fn get_feedback(&self, id: &str) -> Result<SingleFeedbackResponse> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;

        let feedback_doc = self
            .feedback_collection
            .find_one(doc! {"_id":oid })
            .await
            .map_err(MongoQueryError)?;

        match feedback_doc {
            Some(doc) => {
                let feedback = self.doc_to_feedback(&doc)?;
                Ok(SingleFeedbackResponse {
                    status: "success",
                    data: FeedbackData { feedback },
                })
            }
            None => Err(NotFoundError(id.to_string())),
        }
    }

    pub async fn edit_feedback(
        &self,
        id: &str,
        body: &UpdateFeedbackSchema,
    ) -> Result<SingleFeedbackResponse> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;

        let update = doc! {
            "$set": bson::to_document(body).map_err(MongoSerializeBsonError)?,
        };

        if let Some(doc) = self
            .feedback_collection
            .find_one_and_update(doc! {"_id": oid}, update)
            .return_document(ReturnDocument::After)
            .await
            .map_err(MongoQueryError)?
        {
            let feedback = self.doc_to_feedback(&doc)?;
            let feedback_response = SingleFeedbackResponse {
                status: "success",
                data: FeedbackData { feedback },
            };
            Ok(feedback_response)
        } else {
            Err(NotFoundError(id.to_string()))
        }
    }

    pub async fn delete_feedback(&self, id: &str) -> Result<()> {
        let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
        let filter = doc! {"_id": oid };

        let result = self
            .collection
            .delete_one(filter)
            .await
            .map_err(MongoQueryError)?;

        match result.deleted_count {
            0 => Err(NotFoundError(id.to_string())),
            _ => Ok(()),
        }
    }

    fn doc_to_feedback(&self, feedback: &FeedbackModel) -> Result<FeedbackResponse> {
        let feedback_response = FeedbackResponse {
            id: feedback.id.to_hex(),
            name: feedback.name.to_owned(),
            email: feedback.email.to_owned(),
            feedback: feedback.feedback.to_owned(),
            rating: feedback.rating.to_owned(),
            status: feedback.status.to_owned(),
            createdAt: feedback.createdAt,
            updatedAt: feedback.updatedAt,
        };

        Ok(feedback_response)
    }

    fn create_feedback_document(
        &self,
        body: &CreateFeedbackSchema,
        status: String,
    ) -> Result<bson::Document> {
        let serialized_data = bson::to_bson(body).map_err(MongoSerializeBsonError)?;
        let document = serialized_data.as_document().unwrap();

        let datetime = Utc::now();

        let mut doc_with_dates = doc! {
        "createdAt": datetime,
        "updatedAt": datetime,
        "status": status        };
        doc_with_dates.extend(document.clone());

        Ok(doc_with_dates)
    }
}

Process CRUD Requests with Axum

With the database CRUD functions defined, we can now integrate them into our route handlers to process the incoming CRUD requests to our server.

Fetch All Feedbacks

Let’s create an Axum route handler to handle GET requests for retrieving multiple feedback entries. By default, the handler will return the first 10 items, but users can specify limit and page parameters in the request to adjust pagination.

src/handler.rs


pub async fn feedback_list_handler(
    opts: Option<Query<FilterOptions>>,
    State(app_state): 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 i64;
    let page = opts.page.unwrap_or(1) as i64;

    match app_state
        .db
        .fetch_feedbacks(limit, page)
        .await
        .map_err(MyError::from)
    {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

Add a Feedback

Next, let’s handle the POST request to add new feedback to our database. We’ll achieve this by invoking the db.create_feedback() method, and then we will return the newly created document in the JSON response.

src/handler.rs


pub async fn create_feedback_handler(
    State(app_state): State<Arc<AppState>>,
    Json(body): Json<CreateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.create_feedback(&body).await.map_err(MyError::from) {
        Ok(res) => Ok((StatusCode::CREATED, Json(res))),
        Err(e) => Err(e.into()),
    }
}

Fetch a Feedback

The next step is to handle another READ request, where the handler will return a single document in the JSON response.

src/handler.rs


pub async fn get_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.get_feedback(&id).await.map_err(MyError::from) {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

Edit a Feedback

Next, we will process the PATCH request to partially update the document matching the provided ID in the request parameter. The updated document will be returned in the JSON response.

src/handler.rs


pub async fn edit_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
    Json(body): Json<UpdateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state
        .db
        .edit_feedback(&id, &body)
        .await
        .map_err(MyError::from)
    {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

Delete a Feedback

Finally, let’s handle the DELETE request by invoking the db.delete_feedback() method to remove the document matching the provided ID from the collection. Upon a successful deletion, a 204 status code will be returned to the user, indicating that the operation was completed without returning any content.

src/handler.rs


pub async fn delete_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.delete_feedback(&id).await.map_err(MyError::from) {
        Ok(_) => Ok(StatusCode::NO_CONTENT),
        Err(e) => Err(e.into()),
    }
}

Complete Axum CRUD Code

Here’s the complete code for handling the CRUD HTTP requests:

src/handler.rs


use std::sync::Arc;

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

use crate::{
    error::MyError,
    schema::{CreateFeedbackSchema, FilterOptions, UpdateFeedbackSchema},
    AppState,
};

pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Feedback API in Rust using Axum Framework and MongoDB";

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

    Json(json_response)
}

pub async fn feedback_list_handler(
    opts: Option<Query<FilterOptions>>,
    State(app_state): 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 i64;
    let page = opts.page.unwrap_or(1) as i64;

    match app_state
        .db
        .fetch_feedbacks(limit, page)
        .await
        .map_err(MyError::from)
    {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

pub async fn create_feedback_handler(
    State(app_state): State<Arc<AppState>>,
    Json(body): Json<CreateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.create_feedback(&body).await.map_err(MyError::from) {
        Ok(res) => Ok((StatusCode::CREATED, Json(res))),
        Err(e) => Err(e.into()),
    }
}

pub async fn get_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.get_feedback(&id).await.map_err(MyError::from) {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

pub async fn edit_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
    Json(body): Json<UpdateFeedbackSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state
        .db
        .edit_feedback(&id, &body)
        .await
        .map_err(MyError::from)
    {
        Ok(res) => Ok(Json(res)),
        Err(e) => Err(e.into()),
    }
}

pub async fn delete_feedback_handler(
    Path(id): Path<String>,
    State(app_state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    match app_state.db.delete_feedback(&id).await.map_err(MyError::from) {
        Ok(_) => Ok(StatusCode::NO_CONTENT),
        Err(e) => Err(e.into()),
    }
}

Create Axum CRUD Routes

With our route handlers or controllers defined, we can now proceed to invoke them. Below is the code to accomplish this:

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)
}

We used the .with_state() method to pass in our application state, allowing our route handlers to access the MongoDB database instance.

Register the API Router and Set Up CORS

Let’s conclude by registering the API router and configuring CORS for the server.

src/main.rs


mod db;
mod error;
mod handler;
mod model;
mod response;
mod route;
mod schema;

use std::sync::Arc;

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

pub struct AppState {
    db: DB,
}

#[tokio::main]
async fn main() -> Result<(), MyError> {
    dotenv().ok();

    let db = DB::init().await?;

    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: db.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();

    Ok(())
}

If you’ve followed all the previous sections correctly, you can start the Axum development server if you haven’t already. To ensure that our API is functioning as expected, let’s execute the first CRUD operation to check if a document is successfully added to our database.

Open your API client—I’ll be using the Postman extension in VS Code. Enter the URL http://localhost:8000/api/feedbacks/ and include the following JSON object in the request body.


{
    "name": "John Smith",
    "email": "johnsmith@gmail.com",
    "feedback": "Your channel is the best thing that ever happended to me",
    "rating": 4.5,
    "status": "active"
}

Once you’ve completed the setup, send the request. After a few seconds, you should receive a 201 response containing the newly created data.

Conclusion

And we are done! Congratulations on reaching this point. In this tutorial, you learned how to build a CRUD API in Rust using MongoDB as the database and Axum for handling HTTP requests.

I hope you found this guide both helpful and enjoyable. If you have any questions or feedback, please feel free to leave them in the comments section. Thank you for reading!