This comprehensive guide will teach you how to build a CRUD (Create, Read, Update, and Delete) API using the Rust programming language and MongoDB as the database. The Rust API will run on a Warp HTTP server and use the MongoDB Rust driver to persist data in a MongoDB database.
What is Warp in Rust? Warp is a high-performance web server framework for Rust built on top of the low-level hyper library. I chose Warp over Actix-web and Rocket because it has a small footprint and doesn’t come with a lot of built-in functionality, which makes it an excellent choice for building simple and blazingly-fast APIs.
What is MongoDB? MongoDB is a popular NoSQL database that is known for its scalability and ease of use. Together, Rust and MongoDB make a perfect combination for building robust and efficient APIs.
To summarise, we’ll set up a MongoDB database with Docker, create the Rust HTTP server with Warp, and implement the CRUD functionalities in the API. At the end of this tutorial, you will have a solid understanding of how to build a CRUD API with Rust and MongoDB.
More practice:
- Build a Simple API in Rust
- Build a Simple API with Rust and Rocket
- Build a Simple API with Rust and Actix Web
- Build a CRUD API with Node.js and Sequelize
- Build a CRUD App with FastAPI and SQLAlchemy
- Build a CRUD App with FastAPI and PyMongo
- Build CRUD API with Django REST framework
Prerequisites
To fully grasp the knowledge presented in this article, you’ll need the following prerequisites.
- Docker: You should have Docker installed on your machine. This is required to run the MongoDB server.
- You should have a basic understanding of HTTP and Web development concepts.
- You should have basic knowledge of Rust and its ecosystem.
- Some experience in any Rust web framework will be beneficial.
Run the Rust MongoDB CRUD Project Locally
- Download or clone the MongoDB Rust project from https://github.com/wpcodevo/rust-mongodb-crud and open the source code in an IDE.
- Start the MongoDB server in the Docker container by running
docker-compose up -d
in the terminal of the root directory. - Run
cargo r -r
to install the necessary crates and start the Warp HTTP server. - Import the
Note App.postman_collection.json
file into Postman or Thunder Client VS Code extension to test the CRUD API endpoints. Alternatively, you can set up the React app to interact with the API.
Run the Rust API with a Frontend
For a detailed guide on how to build the React.js CRUD app see the article Build a React.js CRUD App using a RESTful API. Nonetheless, you can follow the steps below to spin up the React app within minutes.
- Ensure you have Node.js and Yarn installed.
- Clone or download the React project from https://github.com/wpcodevo/reactjs-crud-note-app and open the source code in a code editor.
- Run
yarn
oryarn install
in the console of the root directory to install the project’s dependencies. - Start the Vite development server by running
yarn dev
. - Open
http://localhost:3000/
to test the CRUD app with the Rust API. Note: Do not open the React app on http://127.0.0.1:3000 to avoid site can’t be reached or CORS error.
Setup the Rust Project
By the end of this tutorial, you’ll have a folder structure that looks like the screenshot below excluding the Makefile and Note App.postman_collection.json
files.
First thing first, find a convenient location on your system and create a folder named rust-mongodb-crud
. Once created, open the folder in a code editor. In this tutorial, I’ll use VS Code as my text editor. Feel free to use any IDE you are more comfortable with.
mkdir rust-mongodb-crud
cd rust-mongodb-crud && code .
Open the integrated terminal in the IDE or text editor and run cargo init
to initialize the Rust project.
cargo init
After the new Cargo manifest file has been created, run the following commands to install the required crates.
cargo add warp
cargo add mongodb --features bson-chrono-0_4
cargo add futures --features async-await --no-default-features
cargo add serde --features derive
cargo add thiserror
cargo add chrono --features serde
cargo add tokio --features full
cargo add dotenv
cargo add pretty_env_logger
warp
– A lightweight Rust web framework.mongodb
– An official MongoDB driver for Rust.futures
– This crate provides a set of abstractions and utilities for writing high-performance, concurrent, and non-blocking code.serde
– For serializing and deserializing data between various formats such as BSON, YAML, JSON, and more.thiserror
– This crate provides a convenient derive macro for the standard library’sstd::error::Error
trait.chrono
– Date and time library for Rusttokio
– Provides a number of features for developing asynchronous, non-blocking I/O applications in Rust.dotenv
– For loading environment variables from a.env
file into the Rust application.pretty_env_logger
– A middleware logger built on top of theenv_logger
crate.
The Cargo.toml
file below contains the crates I installed and their respective versions. If the latest versions of the crates break your app, you can use the versions provided in the Cargo.toml
file.
Cargo.toml
[package]
name = "rust-mongodb-crud"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4.23", features = ["serde"] }
dotenv = "0.15.0"
futures = { version = "0.3.25", default-features = false, features = ["async-await"] }
mongodb = { version = "2.3.1", features = ["bson-chrono-0_4"] }
pretty_env_logger = "0.4.0"
serde = { version = "1.0.152", features = ["derive"] }
thiserror = "1.0.38"
tokio = { version = "1.23.0", features = ["full"] }
warp = "0.3.3"
With that out of the way, let’s build a basic Warp server to respond with a simple JSON object. To do this, open the src/main.rs
file and replace its content with the code below.
src/main.rs
use serde::Serialize;
use warp::{reply::json, Filter, Rejection, Reply};
type WebResult<T> = std::result::Result<T, Rejection>;
#[derive(Serialize)]
pub struct GenericResponse {
pub status: String,
pub message: String,
}
pub async fn health_checker_handler() -> WebResult<impl Reply> {
const MESSAGE: &str = "Build CRUD API with Rust and MongoDB";
let response_json = &GenericResponse {
status: "success".to_string(),
message: MESSAGE.to_string(),
};
Ok(json(response_json))
}
#[tokio::main]
async fn main() {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "api=info");
}
pretty_env_logger::init();
let health_checker = warp::path!("api" / "healthchecker")
.and(warp::get())
.and_then(health_checker_handler);
let routes = health_checker.with(warp::log("api"));
println!("🚀 Server started successfully");
warp::serve(routes).run(([0, 0, 0, 0], 8000)).await;
}
In the above code, we imported the required dependencies at the top of the file and created a GenericResponse struct. The GenericResponse struct is decorated with #[derive(Serialize)]
which allows the struct to be converted to JSON using the Serde crate.
Warp will call the health_checker_handler
function to return the JSON object to the client when a GET request is made to the /api/healthchecker
endpoint.
In the main function, we created the routes for the server, in this case, it’s a path /api/healthchecker
that listens to a GET request, and then calls thehealth_checker_handler
function.
Similar to Nodemon in Node.js, the cargo-watch
CLI tool will allow us to rebuild and hot-reload the server when required files change. If you do not want to restart the server upon file changes, you can use the standard Cargo CLI.
Install the cargo-watch
binary with this command:
cargo install cargo-watch
Run the command below to build the project, start the Warp HTTP server, and reload the server when files within the src directory change.
cargo watch -q -c -w src/ -x run
Once the server is running, you can make a GET request to http://localhost:8000/api/healthchecker
to see the JSON object.
Setup MongoDB
In this section, you’ll use Docker Compose to set up a MongoDB server in a Docker container. To do that, create a docker-compose.yml
file in the root directory and add the following configurations.
docker-compose.yml
version: '3'
services:
mongo:
image: mongo:latest
container_name: mongo
env_file:
- ./.env
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
volumes:
- mongo:/data/db
ports:
- '6000:27017'
volumes:
mongo:
The above code will pull the latest MongoDB Docker image from DockerHub and build the MongoDB server in the Docker container. For security reasons, we’ll load the MongoDB server credentials from a .env
file using the env_file
option instead of manually providing the environment variables under the environment
option.
To make the MongoDB server credentials available, create a .env
file in the root directory 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=notes
DATABASE_URL=mongodb://admin:password123@localhost:6000/rust_mongodb?authSource=admin
Now run the command below to build the mongo
image and start the MongoDB server in the Docker container.
docker-compose up -d
Create the Database Model
Now that the MongoDB server is running, let’s create a Rust struct to represent the MongoDB document. We’ll use this struct to convert the BSON document returned by the MongoDB driver into a struct.
So create a model.rs
file in the src directory and add the following model definitions.
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 NoteModel {
#[serde(rename = "_id")]
pub id: ObjectId,
pub title: String,
pub content: String,
pub category: Option<String>,
pub published: Option<bool>,
#[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 API Response Structs
Now let’s create structs that implement the #[derive(Serialize)]
trait of Serde to help us convert structs into JSON objects before sending them in the JSON response.
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 NoteResponse {
pub id: String,
pub title: String,
pub content: String,
pub category: String,
pub published: bool,
pub createdAt: DateTime<Utc>,
pub updatedAt: DateTime<Utc>,
}
#[derive(Serialize, Debug)]
pub struct NoteData {
pub note: NoteResponse,
}
#[derive(Serialize, Debug)]
pub struct SingleNoteResponse {
pub status: String,
pub data: NoteData,
}
#[derive(Serialize, Debug)]
pub struct NoteListResponse {
pub status: String,
pub results: usize,
pub notes: Vec<NoteResponse>,
}
Create the API Request Structs
In every API development, it’s a good practice to validate user inputs to prevent junk values in the database. To better validate and sanitize the incoming request payloads, you can use the validator
crate.
However, since the project is already getting long, we’ll only use the Serde crate to ensure that the user provides the right data types in the JSON object. In the src folder, create a schema.rs
file and add the following code.
src/schema.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug)]
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 CreateNoteSchema {
pub title: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateNoteSchema {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published: Option<bool>,
}
Handle MongoDB and Application Errors
Error handling is a crucial aspect of software development, and it’s especially important when working with databases such as MongoDB. Handling all the possible MongoDB and application errors will ensure that the API will continue to run even in the event of unexpected input or behavior.
To properly handle the MongoDB and application errors, we’ll create an enum to represent different types of errors such as driver, network, and validation errors. This way, we can handle each error type separately and in a more explicit and controlled way.
So create an error.rs
file in the src directory and add the following code snippets.
src/error.rs
use mongodb::bson;
use std::convert::Infallible;
use thiserror::Error;
use warp::{http::StatusCode, reply, Rejection, Reply};
use crate::response::GenericResponse;
#[derive(Error, Debug)]
pub enum Error {
#[error("mongodb error: {0}")]
MongoError(#[from] mongodb::error::Error),
#[error("error during mongodb query: {0}")]
MongoQueryError(mongodb::error::Error),
#[error("dulicate key error occurred: {0}")]
MongoDuplicateError(mongodb::error::Error),
#[error("could not serialize data: {0}")]
MongoSerializeBsonError(bson::ser::Error),
// #[error("could not deserialize bson: {0}")]
// MongoDeserializeBsonError(bson::de::Error),
#[error("could not access field in document: {0}")]
MongoDataError(#[from] bson::document::ValueAccessError),
#[error("invalid id used: {0}")]
InvalidIDError(String),
}
impl warp::reject::Reject for Error {}
pub async fn handle_rejection(err: Rejection) -> std::result::Result<Box<dyn Reply>, Infallible> {
let code;
let message;
let status;
if err.is_not_found() {
status = "failed";
code = StatusCode::NOT_FOUND;
message = "Route does not exist on the server";
} else if let Some(_) = err.find::<warp::filters::body::BodyDeserializeError>() {
status = "failed";
code = StatusCode::BAD_REQUEST;
message = "Invalid Body";
} else if let Some(e) = err.find::<Error>() {
match e {
Error::MongoError(e) => {
eprintln!("MongoDB error: {:?}", e);
status = "fail";
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "MongoDB error";
}
Error::MongoDuplicateError(e) => {
eprintln!("MongoDB error: {:?}", e);
status = "fail";
code = StatusCode::CONFLICT;
message = "Duplicate key error";
}
Error::MongoQueryError(e) => {
eprintln!("Error during mongodb query: {:?}", e);
status = "fail";
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "Error during mongodb query";
}
Error::MongoSerializeBsonError(e) => {
eprintln!("Error seserializing BSON: {:?}", e);
status = "fail";
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "Error seserializing BSON";
}
// Error::MongoDeserializeBsonError(e) => {
// eprintln!("Error deserializing BSON: {:?}", e);
// status = "fail";
// code = StatusCode::INTERNAL_SERVER_ERROR;
// message = "Error deserializing BSON";
// }
Error::MongoDataError(e) => {
eprintln!("validation error: {:?}", e);
status = "fail";
code = StatusCode::BAD_REQUEST;
message = "validation error";
}
Error::InvalidIDError(e) => {
eprintln!("Invalid ID: {:?}", e);
status = "fail";
code = StatusCode::BAD_REQUEST;
message = e.as_str();
} // _ => {
// eprintln!("unhandled application error: {:?}", err);
// status = "error";
// code = StatusCode::INTERNAL_SERVER_ERROR;
// message = "Internal Server Error";
// }
}
} else if let Some(_) = err.find::<warp::reject::MethodNotAllowed>() {
status = "failed";
code = StatusCode::METHOD_NOT_ALLOWED;
message = "Method Not Allowed";
} else {
eprintln!("unhandled error: {:?}", err);
status = "error";
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "Internal Server Error";
}
let json = reply::json(&GenericResponse {
status: status.into(),
message: message.into(),
});
Ok(Box::new(reply::with_status(json, code)))
}
Create the MongoDB CRUD Functions
Now it’s time to create higher-level CRUD functions over the lower-level CRUD functions provided by the Rust MongoDB driver. To begin, create a db.rs
file in the src directory and add the following modules.
src/db.rs
use crate::response::{NoteData, NoteListResponse, NoteResponse, SingleNoteResponse};
use crate::{
error::Error::*, model::NoteModel, schema::CreateNoteSchema, schema::UpdateNoteSchema, Result,
};
use chrono::prelude::*;
use futures::StreamExt;
use mongodb::bson::{doc, oid::ObjectId, Document};
use mongodb::options::{FindOneAndUpdateOptions, FindOptions, IndexOptions, ReturnDocument};
use mongodb::{bson, options::ClientOptions, Client, Collection, IndexModel};
use std::str::FromStr;
Connect to the MongoDB Database
Before we can implement the CRUD functions, we first need a way to connect the Rust API to the running MongoDB server. To do this, we’ll create a DB
struct to hold the various MongoDB collections and create an init function to connect the Rust server to the MongoDB server and create the different collections in the database.
In the src/db.rs
file, add the following code below the module imports:
src/db.rs
#[derive(Clone, Debug)]
pub struct DB {
pub note_collection: Collection<NoteModel>,
pub collection: Collection<Document>,
}
impl DB {
pub async fn init() -> Result<Self> {
let mongodb_uri: String = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set.");
let database_name: String =
std::env::var("MONGO_INITDB_DATABASE").expect("MONGO_INITDB_DATABASE must be set.");
let mongodb_note_collection: String =
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).unwrap();
let database = client.database(database_name.as_str());
let note_collection = database.collection(mongodb_note_collection.as_str());
let collection = database.collection::<Document>(mongodb_note_collection.as_str());
println!("✅ Database connected successfully");
Ok(Self {
note_collection,
collection,
})
}
}
The init() function will create an instance of the DB
struct and connect to the MongoDB server. The function returns a Result type that contains an instance of the DB
struct on success and an error on failure. We’ll add more functionality to the DB struct like methods to perform the CRUD operations on the MongoDB server.
Now the init
function will use the ClientOptions::parse()
method to parse the MongoDB connection URI to create an instance of the ClientOptions
struct which is then passed to the Client::with_options()
method to create the MongoDB client.
After the MongoDB client has been created, the MongoDB database name we imported from the environment variables file will be passed to the client.database()
method to create a database with the specified name.
Finally, the database.collection()
method will be used to create the different MongoDB collections in the database. The collections will have a bunch of methods that we’ll use to query and mutate the MongoDB database.
Convert Document to Struct
To avoid sending sensitive data to the client, let’s create a utility function to help us filter the data returned by the MongoDB driver.
src/db.rs
impl DB {
// [...] Other code
fn doc_to_note(&self, note: &NoteModel) -> Result<NoteResponse> {
let note_response = NoteResponse {
id: note.id.to_hex(),
title: note.title.to_owned(),
content: note.content.to_owned(),
category: note.category.to_owned().unwrap(),
published: note.published.unwrap(),
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
Ok(note_response)
}
}
Perform Multiple READ Operations
The first CRUD method we’ll implement is READ. The fetch_notes
method will have a pagination feature where users can query a selected number of documents in the database.
First, we need a way to define the MongoDB query options. Luckily, the MongoDB driver provides a FindOptions::builder()
method that can do just that. With the builder function, we can skip a set number of documents and limit the number of results returned by the MongoDB driver.
After that, we’ll pass the query options to the note_collection.find()
method to retrieve the documents from the collection.
src/db.rs
impl DB {
// [...] Other code
pub async fn fetch_notes(&self, limit: i64, page: i64) -> Result<NoteListResponse> {
let find_options = FindOptions::builder()
.limit(limit)
.skip(u64::try_from((page - 1) * limit).unwrap())
.build();
let mut cursor = self
.note_collection
.find(None, find_options)
.await
.map_err(MongoQueryError)?;
let mut json_result: Vec<NoteResponse> = Vec::new();
while let Some(doc) = cursor.next().await {
json_result.push(self.doc_to_note(&doc.unwrap())?);
}
let json_note_list = NoteListResponse {
status: "success".to_string(),
results: json_result.len(),
notes: json_result,
};
Ok(json_note_list)
}
}
Next, we’ll iterate the cursor returned by the note_collection.find()
method and push each document into a vector. Finally, we’ll create an instance of the NoteListResponse struct to hold the list of documents along with some other properties.
Perform CREATE Operation
The second CRUD function we’ll implement is CREATE. This function will be used to insert new documents into the database. To demonstrate how indexes work, we’ll use the IndexOptions::builder().unique()
function to add a unique index to the title field.
Adding a unique index on the title field will ensure that no two documents end up with the same title in the database. If the unique constraint is violated, the MongoDB driver will return a duplicate key error.
src/db.rs
impl DB {
// [...] Other code
pub async fn create_note(&self, body: &CreateNoteSchema) -> Result<Option<SingleNoteResponse>> {
let published = body.published.to_owned().unwrap_or(false);
let category = body.category.to_owned().unwrap_or("".to_string());
let serialized_data = bson::to_bson(&body).map_err(MongoSerializeBsonError)?;
let document = serialized_data.as_document().unwrap();
let options = IndexOptions::builder().unique(true).build();
let index = IndexModel::builder()
.keys(doc! {"title": 1})
.options(options)
.build();
self.note_collection
.create_index(index, None)
.await
.expect("error creating index!");
let datetime = Utc::now();
let mut doc_with_dates = doc! {"createdAt": datetime, "updatedAt": datetime, "published": published, "category": category};
doc_with_dates.extend(document.clone());
let insert_result = self
.collection
.insert_one(&doc_with_dates, None)
.await
.map_err(|e| {
if e.to_string()
.contains("E11000 duplicate key error collection")
{
return MongoDuplicateError(e);
}
return MongoQueryError(e);
})?;
let new_id = insert_result
.inserted_id
.as_object_id()
.expect("issue with new _id");
let note_doc = self
.note_collection
.find_one(doc! {"_id":new_id }, None)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
}
Once the unique index has been created on the Model, we can the collection.insert_one()
method to insert the new document into the database. Since the insert_one function doesn’t return the document that was created, we’ll fire another query with the note_collection.find_one()
method to retrieve the newly-created document.
Perform Single READ Operation
Let’s implement the READ operation of CRUD to retrieve a single document from the database. To do this, we’ll first convert the id
into a MongoDB ObjectId and call the note_collection.find_one()
method to retrieve the first document that matches the query.
src/db.rs
impl DB {
// [...] Other code
pub async fn get_note(&self, id: &str) -> Result<Option<SingleNoteResponse>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let note_doc = self
.note_collection
.find_one(doc! {"_id":oid }, None)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
}
If no document was found, we’ll return None
else the document that was found will be returned.
Perform UPDATE Operation
The third CRUD function we’ll implement is UPDATE. This function will edit the fields of a document in the database. To find a specific document in the database, we need to build the filter option using the doc! {}
macro.
After that, we’ll define the update option using the FindOneAndUpdateOptions::builder()
method. The ReturnDocument::After
argument will tell the MongoDB driver to return the updated form of the document.
Next, we’ll serialize the request payload into BSON, convert the BSON into a document, and construct the update
document using the doc! {}
macro and the $set
operator.
Then, we’ll evoke the note_collection.find_one_and_update()
method with the required options to edit the document that matches the query.
src/db.rs
impl DB {
// [...] Other code
pub async fn edit_note(
&self,
id: &str,
body: &UpdateNoteSchema,
) -> Result<Option<SingleNoteResponse>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let query = doc! {
"_id": oid,
};
let find_one_and_update_options = FindOneAndUpdateOptions::builder()
.return_document(ReturnDocument::After)
.build();
let serialized_data = bson::to_bson(body).map_err(MongoSerializeBsonError)?;
let document = serialized_data.as_document().unwrap();
let update = doc! {"$set": document};
let note_doc = self
.note_collection
.find_one_and_update(query, update, find_one_and_update_options)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
}
If a document was updated in the database, we’ll return the updated document from the function. Otherwise, None
will be returned.
Perform DELETE Operation
Finally, let’s implement the DELETE operation of CRUD to delete a document from the database. To achieve this, we’ll first convert the id
into a MongoDB ObjectId and call the note_collection.delete_one()
method to delete the document that matches the query.
src/db.rs
impl DB {
// [...] Other code
pub async fn delete_note(&self, id: &str) -> Result<Option<()>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let result = self
.collection
.delete_one(doc! {"_id":oid }, None)
.await
.map_err(MongoQueryError)?;
if result.deleted_count == 0 {
return Ok(None);
}
Ok(Some(()))
}
}
If no document was deleted then it means no document with that ID exists. In this case, we’ll return None
.
Complete MongoDB CRUD Code
src/db.rs
use crate::response::{NoteData, NoteListResponse, NoteResponse, SingleNoteResponse};
use crate::{
error::Error::*, model::NoteModel, schema::CreateNoteSchema, schema::UpdateNoteSchema, Result,
};
use chrono::prelude::*;
use futures::StreamExt;
use mongodb::bson::{doc, oid::ObjectId, Document};
use mongodb::options::{FindOneAndUpdateOptions, FindOptions, IndexOptions, ReturnDocument};
use mongodb::{bson, options::ClientOptions, Client, Collection, IndexModel};
use std::str::FromStr;
#[derive(Clone, Debug)]
pub struct DB {
pub note_collection: Collection<NoteModel>,
pub collection: Collection<Document>,
}
impl DB {
pub async fn init() -> Result<Self> {
let mongodb_uri: String = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set.");
let database_name: String =
std::env::var("MONGO_INITDB_DATABASE").expect("MONGO_INITDB_DATABASE must be set.");
let mongodb_note_collection: String =
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).unwrap();
let database = client.database(database_name.as_str());
let note_collection = database.collection(mongodb_note_collection.as_str());
let collection = database.collection::<Document>(mongodb_note_collection.as_str());
println!("✅ Database connected successfully");
Ok(Self {
note_collection,
collection,
})
}
pub async fn fetch_notes(&self, limit: i64, page: i64) -> Result<NoteListResponse> {
let find_options = FindOptions::builder()
.limit(limit)
.skip(u64::try_from((page - 1) * limit).unwrap())
.build();
let mut cursor = self
.note_collection
.find(None, find_options)
.await
.map_err(MongoQueryError)?;
let mut json_result: Vec<NoteResponse> = Vec::new();
while let Some(doc) = cursor.next().await {
json_result.push(self.doc_to_note(&doc.unwrap())?);
}
let json_note_list = NoteListResponse {
status: "success".to_string(),
results: json_result.len(),
notes: json_result,
};
Ok(json_note_list)
}
pub async fn create_note(&self, body: &CreateNoteSchema) -> Result<Option<SingleNoteResponse>> {
let published = body.published.to_owned().unwrap_or(false);
let category = body.category.to_owned().unwrap_or("".to_string());
let serialized_data = bson::to_bson(&body).map_err(MongoSerializeBsonError)?;
let document = serialized_data.as_document().unwrap();
let options = IndexOptions::builder().unique(true).build();
let index = IndexModel::builder()
.keys(doc! {"title": 1})
.options(options)
.build();
self.note_collection
.create_index(index, None)
.await
.expect("error creating index!");
let datetime = Utc::now();
let mut doc_with_dates = doc! {"createdAt": datetime, "updatedAt": datetime, "published": published, "category": category};
doc_with_dates.extend(document.clone());
let insert_result = self
.collection
.insert_one(&doc_with_dates, None)
.await
.map_err(|e| {
if e.to_string()
.contains("E11000 duplicate key error collection")
{
return MongoDuplicateError(e);
}
return MongoQueryError(e);
})?;
let new_id = insert_result
.inserted_id
.as_object_id()
.expect("issue with new _id");
let note_doc = self
.note_collection
.find_one(doc! {"_id":new_id }, None)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
pub async fn get_note(&self, id: &str) -> Result<Option<SingleNoteResponse>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let note_doc = self
.note_collection
.find_one(doc! {"_id":oid }, None)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
pub async fn edit_note(
&self,
id: &str,
body: &UpdateNoteSchema,
) -> Result<Option<SingleNoteResponse>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let query = doc! {
"_id": oid,
};
let find_one_and_update_options = FindOneAndUpdateOptions::builder()
.return_document(ReturnDocument::After)
.build();
let serialized_data = bson::to_bson(body).map_err(MongoSerializeBsonError)?;
let document = serialized_data.as_document().unwrap();
let update = doc! {"$set": document};
let note_doc = self
.note_collection
.find_one_and_update(query, update, find_one_and_update_options)
.await
.map_err(MongoQueryError)?;
if note_doc.is_none() {
return Ok(None);
}
let note_response = SingleNoteResponse {
status: "success".to_string(),
data: NoteData {
note: self.doc_to_note(¬e_doc.unwrap()).unwrap(),
},
};
Ok(Some(note_response))
}
pub async fn delete_note(&self, id: &str) -> Result<Option<()>> {
let oid = ObjectId::from_str(id).map_err(|_| InvalidIDError(id.to_owned()))?;
let result = self
.collection
.delete_one(doc! {"_id":oid }, None)
.await
.map_err(MongoQueryError)?;
if result.deleted_count == 0 {
return Ok(None);
}
Ok(Some(()))
}
fn doc_to_note(&self, note: &NoteModel) -> Result<NoteResponse> {
let note_response = NoteResponse {
id: note.id.to_hex(),
title: note.title.to_owned(),
content: note.content.to_owned(),
category: note.category.to_owned().unwrap(),
published: note.published.unwrap(),
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
Ok(note_response)
}
}
Implement the CRUD Functions
Now that we’ve created methods on the DB
struct to perform the CRUD operations against the MongoDB database, let’s create Warp route functions to evoke them. First, create a handler.rs
file in the src folder and add the following dependencies.
src/handler.rs
use crate::{
db::DB,
response::GenericResponse,
schema::UpdateNoteSchema,
schema::{CreateNoteSchema, FilterOptions},
WebResult,
};
use warp::{http::StatusCode, reject, reply::json, reply::with_status, Reply};
Retrieve Documents
This route function will be called to retrieve a selected list of documents from the database when a GET request is made to the /api/notes?page=1&limit=10
endpoint.
The notes_list_handler
function will evoke the db.fetch_notes()
CRUD method with the appropriate arguments to retrieve a paginated list of documents and return them in the JSON response.
src/handler.rs
pub async fn notes_list_handler(opts: FilterOptions, db: DB) -> WebResult<impl Reply> {
let limit = opts.limit.unwrap_or(10) as i64;
let page = opts.page.unwrap_or(1) as i64;
let result_json = db
.fetch_notes(limit, page)
.await
.map_err(|e| reject::custom(e))?;
Ok(json(&result_json))
}
Create a New Document
Warp will evoke this route function to add a new document to the database when a POST request hits the server at the /api/notes/
path.
When Warp delegates the POST request to thecreate_note_handler
function, the db.create_note()
CRUD method will be evoked to add the new document to the database. After that, the newly-created document will be returned in the JSON response.
src/handler.rs
pub async fn create_note_handler(body: CreateNoteSchema, db: DB) -> WebResult<impl Reply> {
let note = db.create_note(&body).await.map_err(|e| reject::custom(e))?;
Ok(with_status(json(¬e), StatusCode::CREATED))
}
Get a Single Document
This route function will be called to fetch a single document from the database when a GET request is made to the /api/notes/{id}
endpoint.
When the GET request reaches the get_note_handler
function, the db.get_note()
CRUD method will be called to retrieve the document that matches the ID provided in the URL parameter.
If no document with that ID exists, a 404 Not Found error will be returned to the client.
src/handler.rs
pub async fn get_note_handler(id: String, db: DB) -> WebResult<impl Reply> {
let note = db.get_note(&id).await.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if note.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(¬e), StatusCode::OK))
}
Edit a Document
Warp will call this route function to edit the fields of a document when a PATCH request is made to the /api/notes/{id}
endpoint.
When Warp forwards the PATCH request to the edit_note_handler
function, the db.edit_note()
CRUD method will be called to edit the fields of the document that matches the ID based on the data provided in the request body.
If no document matches the query, a 404 Not Found error will be returned to the client.
src/handler.rs
pub async fn edit_note_handler(
id: String,
body: UpdateNoteSchema,
db: DB,
) -> WebResult<impl Reply> {
let note = db
.edit_note(&id, &body)
.await
.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if note.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(¬e), StatusCode::OK))
}
Delete a Document
This route function will be called to remove a document from the database when a DELETE request is made to the /api/notes/{id}
endpoint.
When Warp delegates the DELETE request to the delete_note_handler
function, the db.delete_note()
CRUD method will be called to delete the document that matches the ID provided in the URL parameter.
If no document with the provided ID exists, a 404 Not Found error will be sent to the client.
src/handler.rs
pub async fn delete_note_handler(id: String, db: DB) -> WebResult<impl Reply> {
let result = db.delete_note(&id).await.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if result.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(&""), StatusCode::NO_CONTENT))
}
Complete CRUD Route Handlers
src/handler.rs
use crate::{
db::DB,
response::GenericResponse,
schema::UpdateNoteSchema,
schema::{CreateNoteSchema, FilterOptions},
WebResult,
};
use warp::{http::StatusCode, reject, reply::json, reply::with_status, Reply};
pub async fn health_checker_handler() -> WebResult<impl Reply> {
const MESSAGE: &str = "Build CRUD API with Rust and MongoDB";
let response_json = &GenericResponse {
status: "success".to_string(),
message: MESSAGE.to_string(),
};
Ok(json(response_json))
}
pub async fn notes_list_handler(opts: FilterOptions, db: DB) -> WebResult<impl Reply> {
let limit = opts.limit.unwrap_or(10) as i64;
let page = opts.page.unwrap_or(1) as i64;
let result_json = db
.fetch_notes(limit, page)
.await
.map_err(|e| reject::custom(e))?;
Ok(json(&result_json))
}
pub async fn create_note_handler(body: CreateNoteSchema, db: DB) -> WebResult<impl Reply> {
let note = db.create_note(&body).await.map_err(|e| reject::custom(e))?;
Ok(with_status(json(¬e), StatusCode::CREATED))
}
pub async fn get_note_handler(id: String, db: DB) -> WebResult<impl Reply> {
let note = db.get_note(&id).await.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if note.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(¬e), StatusCode::OK))
}
pub async fn edit_note_handler(
id: String,
body: UpdateNoteSchema,
db: DB,
) -> WebResult<impl Reply> {
let note = db
.edit_note(&id, &body)
.await
.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if note.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(¬e), StatusCode::OK))
}
pub async fn delete_note_handler(id: String, db: DB) -> WebResult<impl Reply> {
let result = db.delete_note(&id).await.map_err(|e| reject::custom(e))?;
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Note with ID: {} not found", id),
};
if result.is_none() {
return Ok(with_status(json(&error_response), StatusCode::NOT_FOUND));
}
Ok(with_status(json(&""), StatusCode::NO_CONTENT))
}
Register the Routes and Add CORS
Now that we’ve created all the route functions, let’s create routes to evoke them and configure the Warp server with CORS. So open the src/main.rs
file and replace its content with the following code.
src/main.rs
mod db;
mod error;
mod handler;
mod model;
mod response;
mod schema;
use db::DB;
use dotenv::dotenv;
use schema::FilterOptions;
use std::convert::Infallible;
use warp::{http::Method, Filter, Rejection};
type Result<T> = std::result::Result<T, error::Error>;
type WebResult<T> = std::result::Result<T, Rejection>;
#[tokio::main]
async fn main() -> Result<()> {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "api=info");
}
pretty_env_logger::init();
dotenv().ok();
let db = DB::init().await?;
let cors = warp::cors()
.allow_methods(&[Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_origins(vec!["http://localhost:3000"])
.allow_headers(vec!["content-type"])
.allow_credentials(true);
let note_router = warp::path!("api" / "notes");
let note_router_id = warp::path!("api" / "notes" / String);
let health_checker = warp::path!("api" / "healthchecker")
.and(warp::get())
.and_then(handler::health_checker_handler);
let note_routes = note_router
.and(warp::post())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(handler::create_note_handler)
.or(note_router
.and(warp::get())
.and(warp::query::<FilterOptions>())
.and(with_db(db.clone()))
.and_then(handler::notes_list_handler));
let note_routes_id = note_router_id
.and(warp::patch())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(handler::edit_note_handler)
.or(note_router_id
.and(warp::get())
.and(with_db(db.clone()))
.and_then(handler::get_note_handler))
.or(note_router_id
.and(warp::delete())
.and(with_db(db.clone()))
.and_then(handler::delete_note_handler));
let routes = note_routes
.with(warp::log("api"))
.or(note_routes_id)
.or(health_checker)
.with(cors)
.recover(error::handle_rejection);
println!("🚀 Server started successfully");
warp::serve(routes).run(([0, 0, 0, 0], 8000)).await;
Ok(())
}
fn with_db(db: DB) -> impl Filter<Extract = (DB,), Error = Infallible> + Clone {
warp::any().map(move || db.clone())
}
Now start the Warp server again and make HTTP requests to test the CRUD endpoints.
cargo watch -q -c -w src/ -x run
Conclusion
In this article, you learned how to build a CRUD API in Rust using MongoDB as the data store. In addition, you learned how to set up a MongoDB server in a Docker container.
You can find the complete source code of the Rust MongoDB CRUD project on GitHub.