» Rust: Build a REST API with Rocket » 2. Development » 2.8 Database: MongoDB

Database: mongoDB

If you prefer NoSQL databases, mongoDB is definitely a great one that you don't want to miss.

Try mongoDB

  1. Install mongoDB on your machine and start it.

Note: Remember to create indexes on collections based on your need in a production project.

  1. Add mongo dependency:
cargo add mongodb

Diff in Cargo.toml:

@@ -8,6 +8,7 @@ edition = "2021"
 [dependencies]
 chrono = { version = "0.4.35", features = ["serde"] }
 lazy_static = "1.4.0"
+mongodb = { version = "2.8.2", features = ["sync"] }
 mysql = "24.0.0"
 rocket = { version = "0.5.0", features = ["json"] }
 rusqlite = "0.31.0"
  1. Update code.

Use mongoDB for CRUD operations

Add a new domain entity Review in domain/model/review.rs:

use chrono::{DateTime, Utc};

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Review {
    pub id: String,
    pub book_id: u32,
    pub author: String,
    pub title: String,
    pub content: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

Declare its business capabilities in domain/gateway/review_manager.rs:

use std::error::Error;

use crate::domain::model;

pub trait ReviewManager: Send + Sync {
    fn create_review(&self, b: &model::Review) -> Result<String, Box<dyn Error>>;
    fn update_review(&self, id: &str, b: &model::Review) -> Result<(), Box<dyn Error>>;
    fn delete_review(&self, id: &str) -> Result<(), Box<dyn Error>>;
    fn get_review(&self, id: &str) -> Result<Option<model::Review>, Box<dyn Error>>;
    fn get_reviews_of_book(&self, book_id: u32) -> Result<Vec<model::Review>, Box<dyn Error>>;
}

Implement these methods in infrastructure/database/mongo.rs:

use std::error::Error;

use mongodb::{
    bson::{doc, oid::ObjectId, DateTime},
    error::Error as MongoError,
    sync::{Client, Collection},
};

use crate::domain::gateway::ReviewManager;
use crate::domain::model::Review;

const COLL_REVIEW: &str = "reviews";
const ID_FIELD: &str = "_id";

pub struct MongoPersistence {
    coll: Collection<Review>,
}

impl MongoPersistence {
    pub fn new(mongo_uri: &str, db_name: &str) -> Result<Self, MongoError> {
        let client = Client::with_uri_str(mongo_uri)?;
        let coll = client.database(db_name).collection::<Review>(COLL_REVIEW);
        Ok(Self { coll })
    }
}

impl ReviewManager for MongoPersistence {
    fn create_review(&self, review: &Review) -> Result<String, Box<dyn Error>> {
        let result = self.coll.insert_one(review.clone(), None)?;
        let inserted_id = result
            .inserted_id
            .as_object_id()
            .expect("Failed to extract inserted ID");
        Ok(inserted_id.to_hex())
    }

    fn update_review(&self, id: &str, review: &Review) -> Result<(), Box<dyn Error>> {
        let object_id = ObjectId::parse_str(id)?;
        let update_values = doc! {
            "title": &review.title,
            "content": &review.content,
            "updated_at": DateTime::now(),
        };
        let filter = doc! { ID_FIELD: object_id };
        let _result = self
            .coll
            .update_one(filter, doc! { "$set": update_values }, None)?;
        Ok(())
    }

    fn delete_review(&self, id: &str) -> Result<(), Box<dyn Error>> {
        let object_id = ObjectId::parse_str(id)?;
        self.coll.delete_one(doc! { ID_FIELD: object_id }, None)?;
        Ok(())
    }

    fn get_review(&self, id: &str) -> Result<Option<Review>, Box<dyn Error>> {
        let object_id = ObjectId::parse_str(id)?;
        let filter = doc! { ID_FIELD: object_id };
        let review = self.coll.find_one(filter, None)?;
        if let Some(review_doc) = review {
            Ok(Some(Review {
                id: id.to_string(),
                ..review_doc
            }))
        } else {
            Ok(None)
        }
    }

    fn get_reviews_of_book(&self, book_id: u32) -> Result<Vec<Review>, Box<dyn Error>> {
        let filter = doc! { "book_id": book_id };
        let cursor = self.coll.find(filter, None)?;
        let mut reviews = Vec::new();
        for result in cursor {
            reviews.push(result?);
        }
        Ok(reviews)
    }
}

Add config items for mongodb in infrastructure/config/mod.rs:

@@ -12,6 +12,8 @@ pub struct Config {
 pub struct DBConfig {
     pub file_name: String,
     pub dsn: String,
+    pub mongo_uri: String,
+    pub mongo_db_name: String,
 }
 
 #[derive(Debug, Deserialize, Serialize)]

Add config values in config.toml:

@@ -4,3 +4,5 @@ port = 8080
 [db]
 file_name = "test.db"
 dsn = "mysql://test_user:test_pass@127.0.0.1:3306/lr_book"
+mongo_uri = "mongodb://localhost:27017"
+mongo_db_name = "lr_book"

Add review_operator in Application layer, application/executor/review_operator.rs:

use std::sync::Arc;

use chrono::Utc;

use crate::application::dto;
use crate::domain::gateway;
use crate::domain::model;

pub struct ReviewOperator {
    review_manager: Arc<dyn gateway::ReviewManager>,
}

impl ReviewOperator {
    pub fn new(b: Arc<dyn gateway::ReviewManager>) -> Self {
        ReviewOperator { review_manager: b }
    }

    pub fn create_review(
        &self,
        body: &dto::ReviewBody,
    ) -> Result<model::Review, Box<dyn std::error::Error>> {
        let now = Utc::now();
        let review = model::Review {
            id: String::new(),
            book_id: body.book_id,
            author: body.author.clone(),
            title: body.title.clone(),
            content: body.content.clone(),
            created_at: now,
            updated_at: now,
        };
        let id = self.review_manager.create_review(&review)?;
        Ok(model::Review { id, ..review })
    }

    pub fn get_review(
        &self,
        id: &str,
    ) -> Result<Option<model::Review>, Box<dyn std::error::Error>> {
        self.review_manager.get_review(id)
    }

    pub fn get_reviews_of_book(
        &self,
        book_id: u32,
    ) -> Result<Vec<model::Review>, Box<dyn std::error::Error>> {
        self.review_manager.get_reviews_of_book(book_id)
    }

    pub fn update_review(
        &self,
        id: &str,
        body: dto::ReviewBody,
    ) -> Result<model::Review, Box<dyn std::error::Error>> {
        if body.title.is_empty() || body.content.is_empty() {
            return Err("Required field cannot be empty".into());
        }
        let now = Utc::now();
        let review = model::Review {
            id: id.to_string(),
            book_id: body.book_id,
            author: body.author.clone(),
            title: body.title.clone(),
            content: body.content.clone(),
            created_at: now,
            updated_at: now,
        };
        self.review_manager.update_review(id, &review)?;
        Ok(review)
    }

    pub fn delete_review(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
        self.review_manager.delete_review(id)
    }
}

The ReviewBody struct is defined in application/dto/review.rs:

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReviewBody {
    pub book_id: u32,
    pub author: String,
    pub title: String,
    pub content: String,
}

Tune application/wire_helper.rs to wire in mongodb connections:

@@ -5,16 +5,28 @@ use crate::infrastructure::database;
 use crate::infrastructure::Config;
 
 pub struct WireHelper {
-    persistence: Arc<database::MySQLPersistence>,
+    sql_persistence: Arc<database::MySQLPersistence>,
+    no_sql_persistence: Arc<database::MongoPersistence>,
 }
 
 impl WireHelper {
     pub fn new(c: &Config) -> Result<Self, Box<dyn std::error::Error>> {
-        let persistence = Arc::new(database::MySQLPersistence::new(&c.db.dsn)?);
-        Ok(WireHelper { persistence })
+        let sql_persistence = Arc::new(database::MySQLPersistence::new(&c.db.dsn)?);
+        let no_sql_persistence = Arc::new(database::MongoPersistence::new(
+            &c.db.mongo_uri,
+            &c.db.mongo_db_name,
+        )?);
+        Ok(WireHelper {
+            sql_persistence,
+            no_sql_persistence,
+        })
     }
 
     pub fn book_manager(&self) -> Arc<dyn gateway::BookManager> {
-        Arc::clone(&self.persistence) as Arc<dyn gateway::BookManager>
+        Arc::clone(&self.sql_persistence) as Arc<dyn gateway::BookManager>
+    }
+
+    pub fn review_manager(&self) -> Arc<dyn gateway::ReviewManager> {
+        Arc::clone(&self.no_sql_persistence) as Arc<dyn gateway::ReviewManager>
     }
 }

Add review routes in adapter/router.rs:

@@ -3,11 +3,13 @@ use rocket::response::{content, status};
 use rocket::serde::json::Json;
 
 use crate::application;
+use crate::application::dto;
 use crate::application::executor;
 use crate::domain::model;
 
 pub struct RestHandler {
     book_operator: executor::BookOperator,
+    review_operator: executor::ReviewOperator,
 }
 
 #[derive(serde::Serialize)]
@@ -113,8 +115,104 @@ pub fn delete_book(
     }
 }
 
+#[get("/books/<id>/reviews")]
+pub fn get_reviews_of_book(
+    rest_handler: &rocket::State<RestHandler>,
+    id: u32,
+) -> Result<Json<Vec<model::Review>>, status::Custom<Json<ErrorResponse>>> {
+    match rest_handler.review_operator.get_reviews_of_book(id) {
+        Ok(reviews) => Ok(Json(reviews)),
+        Err(err) => Err(status::Custom(
+            Status::InternalServerError,
+            Json(ErrorResponse {
+                error: err.to_string(),
+            }),
+        )),
+    }
+}
+
+#[get("/reviews/<id>")]
+pub fn get_review(
+    rest_handler: &rocket::State<RestHandler>,
+    id: &str,
+) -> Result<Json<model::Review>, status::Custom<Json<ErrorResponse>>> {
+    match rest_handler.review_operator.get_review(id) {
+        Ok(review) => match review {
+            Some(r) => Ok(Json(r)),
+            None => Err(status::Custom(
+                Status::NotFound,
+                Json(ErrorResponse {
+                    error: format!("review {id} not found"),
+                }),
+            )),
+        },
+        Err(err) => Err(status::Custom(
+            Status::InternalServerError,
+            Json(ErrorResponse {
+                error: err.to_string(),
+            }),
+        )),
+    }
+}
+
+#[post("/reviews", format = "json", data = "<review>")]
+pub fn create_review(
+    rest_handler: &rocket::State<RestHandler>,
+    review: Json<dto::ReviewBody>,
+) -> Result<Json<model::Review>, status::Custom<Json<ErrorResponse>>> {
+    match rest_handler
+        .review_operator
+        .create_review(&review.into_inner())
+    {
+        Ok(b) => Ok(Json(b)),
+        Err(err) => Err(status::Custom(
+            Status::InternalServerError,
+            Json(ErrorResponse {
+                error: err.to_string(),
+            }),
+        )),
+    }
+}
+
+#[put("/reviews/<id>", format = "json", data = "<review>")]
+pub fn update_review(
+    rest_handler: &rocket::State<RestHandler>,
+    id: &str,
+    review: Json<dto::ReviewBody>,
+) -> Result<Json<model::Review>, status::Custom<Json<ErrorResponse>>> {
+    match rest_handler
+        .review_operator
+        .update_review(id, review.into_inner())
+    {
+        Ok(b) => Ok(Json(b)),
+        Err(err) => Err(status::Custom(
+            Status::InternalServerError,
+            Json(ErrorResponse {
+                error: err.to_string(),
+            }),
+        )),
+    }
+}
+
+#[delete("/reviews/<id>")]
+pub fn delete_review(
+    rest_handler: &rocket::State<RestHandler>,
+    id: &str,
+) -> Result<status::NoContent, status::Custom<Json<ErrorResponse>>> {
+    match rest_handler.review_operator.delete_review(id) {
+        Ok(_) => Ok(status::NoContent),
+        Err(err) => Err(status::Custom(
+            Status::InternalServerError,
+            Json(ErrorResponse {
+                error: err.to_string(),
+            }),
+        )),
+    }
+}
+
 pub fn make_router(wire_helper: &application::WireHelper) -> RestHandler {
     RestHandler {
         book_operator: executor::BookOperator::new(wire_helper.book_manager()),
+        review_operator: executor::ReviewOperator::new(wire_helper.review_manager()),
     }
 }

All the changes are applied now. Let's try these endpoints with curl.

Try with curl

Create a new review:

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "book_id": 1,
        "author": "John Doe",
        "title": "Great Book",
        "content": "This is a great book!"
      }' \
  http://localhost:8000/reviews

It should respond with this:

{"id":"65f6e07ff274dab9361db624","book_id":1,"author":"John Doe","title":"Great Book","content":"This is a great book!","created_at":"2024-03-17 12:22:23","updated_at":"2024-03-17 12:22:23"}

Fetch a single review by ID:

curl -X GET http://localhost:8000/reviews/65f6e07ff274dab9361db624

Result:

{
  "id": "65f6e07ff274dab9361db624",
  "book_id": 1,
  "author": "John Doe",
  "title": "Great Book",
  "content": "This is a great book!",
  "created_at": "2024-03-17 12:22:23",
  "updated_at": "2024-03-17 12:22:23"
}

List all reviews of a book:

curl -X GET http://localhost:8000/books/1/reviews

Result list:

[
  {
    "id": "",
    "book_id": 1,
    "author": "John Doe",
    "title": "Great Book",
    "content": "This is a great book!",
    "created_at": "2024-03-17T15:36:08.059307Z",
    "updated_at": "2024-03-17T15:36:08.059307Z"
  }
]

Update an existing review

curl -X PUT \
  -H "Content-Type: application/json" \
  -d '{
        "book_id": 1,
        "author": "John Doe",
        "content": "I prefer Robert Smith new book",
        "title": "Not that good"
      }' \
  http://localhost:8000/reviews/65f6e07ff274dab9361db624

Result:

{"id":"65f6e07ff274dab9361db624","book_id":1,"author":"John Doe","title":"Not that good","content":"I prefer Robert Smith new book","created_at":"","updated_at":"2024-03-17 15:00:24"}

Delete an existing review:

curl -X DELETE http://localhost:8000/reviews/65f6e07ff274dab9361db624

It returns code 204 for a sucessful deletion.

Voila! Your api server is using mongoDB as well now!