Mario Zupan I'm a software developer originally from Graz but living in Vienna, Austria. I previously worked as a full-stack web developer before quitting my job to work as a freelancer and explore open source. Currently, I work at timeular.

Using MongoDB in a Rust web service

6 min read 1837

Using MongoDB in a Rust Web Service

In a previous post, we covered how to create a web API in Rust with Postgres. This time around, we’ll look at how to create a similar app with MongoDB, a very popular and widely used document database.

The underlying infrastructure for this application will be the same as in the aforementioned post to show the similarities and differences when switching from a relational to an object database in a Rust web service.

Luckily, MongoDB can be used either synchronously and asynchronously with the official MongoDB Rust library, which means this example will also be fully asynchronous. We’ll demonstrate this concept by building a CRUD API for books.

Setup

To follow along, all you need is a reasonably recent Rust installation (1.39+). A way to start a local MongoDB instance, such as Docker, and a tool to send HTTP requests, such as curl or Postman, would also be useful.

First, create a new Rust project.

cargo new rust-web-mongodb-example
cd rust-web-mongodb-example

Next, edit the Cargo.toml file and add the dependencies you’ll need.

[dependencies]
tokio = { version = "0.2", features = ["macros", "rt-threaded"] }
warp = "0.2"
serde = {version = "1.0", features = ["derive"] }
thiserror = "1.0"
chrono = { version = "0.4", features = ["serde"] }
futures = { version = "0.3.4", default-features = false, features = ["async-await"]}
mongodb = "1.0.0"

For this example, we’ll use the official Rust MongoDB Driver. The web application is based on warp, and we’ll add a few helper libraries, such as serde for handling serialization, chrono for dealing with time, and thiserror for handling errors.

Structure

To build this small example application, we’ll create three modules:

  1. db — database access logic
  2. error — errors and HTTP error response handling
  3. handler — CRUD HTTP handlers

Let’s start with the database access and work our way upward. We’ll wire everything together in main at the end.

Database access

First, let’s create a small abstraction for database access, which we can pass around the application.

We made a custom demo for .
No really. Click here to check it out.

use mongodb::bson::{doc, document::Document, oid::ObjectId, Bson};
use mongodb::{options::ClientOptions, Client, Collection};

const DB_NAME: &str = "booky";
const COLL: &str = "books";

const ID: &str = "_id";
const NAME: &str = "name";
const AUTHOR: &str = "author";
const NUM_PAGES: &str = "num_pages";
const ADDED_AT: &str = "added_at";
const TAGS: &str = "tags";

#[derive(Clone, Debug)]
pub struct DB {
    pub client: Client,
}

impl DB {
...
}

Next, we need to establish a way to initialize a MongoDB client.

impl DB {
    pub async fn init() -> Result<Self> {
        let mut client_options = ClientOptions::parse("mongodb://127.0.0.1:27017").await?;
        client_options.app_name = Some("booky".to_string());
        Ok(Self {
            client: Client::with_options(client_options)?,
        })
    }
}

Creating a MongoDB client in Rust is quite simple. Simply parse a connection string to create ClientOptions, set an application name, and initialize the client with the options.

Let’s walk through how to make a simple query (finding all books in the database).

    pub async fn fetch_books(&self) -> Result<Vec<Book>> {
        let mut cursor = self
            .get_collection()
            .find(None, None)
            .await
            .map_err(MongoQueryError)?;

        let mut result: Vec<Book> = Vec::new();
        while let Some(doc) = cursor.next().await {
            result.push(self.doc_to_book(&doc?)?);
        }
        Ok(result)
    }

    fn get_collection(&self) -> Collection {
        self.client.database(DB_NAME).collection(COLL)
    }

The first step is to select a collection using the get_collection() helper. This operation doesn’t actually talk to the database; it just sets the collection context we want to operate in.

Next, execute the find() command on this collection. In this case, we’ll pass no filter and no other options, so both parameters are set to None. We simply want all objects.

Awaiting the result of find gives us a Cursor, which is a Stream. This stream can be iterated. Once it’s exhausted, if there is more data, the cursor will automatically fetch the next chunk of data until no more is left.

To get from these documents to actual Books —

#[derive(Serialize, Deserialize, Debug)]
pub struct Book {
    pub id: String,
    pub name: String,
    pub author: String,
    pub num_pages: usize,
    pub added_at: DateTime<Utc>,
    pub tags: Vec<String>,
}

— create the doc_to_book helper.

    fn doc_to_book(&self, doc: &Document) -> Result<Book> {
        let id = doc.get_object_id(ID)?;
        let name = doc.get_str(NAME)?;
        let author = doc.get_str(AUTHOR)?;
        let num_pages = doc.get_i32(NUM_PAGES)?;
        let added_at = doc.get_datetime(ADDED_AT)?;
        let tags = doc.get_array(TAGS)?;

        let book = Book {
            id: id.to_hex(),
            name: name.to_owned(),
            author: author.to_owned(),
            num_pages: num_pages as usize,
            added_at: *added_at,
            tags: tags
                .iter()
                .filter_map(|entry| match entry {
                    Bson::String(v) => Some(v.to_owned()),
                    _ => None,
                })
                .collect(),
        };
        Ok(book)
    }

This helper tries to parse the fields of the returned document based on the data model we want the document to represent. Any of these conversions can fail, of course.

What’s interesting here is that we can use the different get_$type(key) functions on the document to convert the data to the types we need. For example, the official MongoDB library supports chrono::DateTime<Utc> for dates, which is very convenient. So we can simply use doc.get_datetime(key) to convert a date/time within MongoDB to a chrono type.

Another useful helper is .get_array(key), which, as the name suggests, tries to convert the value at the given key to a Vec. In this case, however, we need to make sure the values are actually Strings since, in MongoDB, they could be of different types.

CRUD handlers

The handlers are rather simple; all they do is receive some input and call the database abstraction.

#[derive(Serialize, Deserialize, Debug)]
pub struct BookRequest {
    pub name: String,
    pub author: String,
    pub num_pages: usize,
    pub tags: Vec<String>,
}

pub async fn books_list_handler(db: DB) -> WebResult<impl Reply> {
    let books = db.fetch_books().await.map_err(|e| reject::custom(e))?;
    Ok(json(&books))
}

pub async fn create_book_handler(body: BookRequest, db: DB) -> WebResult<impl Reply> {
    db.create_book(&body).await.map_err(|e| reject::custom(e))?;
    Ok(StatusCode::CREATED)
}

pub async fn edit_book_handler(id: String, body: BookRequest, db: DB) -> WebResult<impl Reply> {
    db.edit_book(&id, &body)
        .await
        .map_err(|e| reject::custom(e))?;
    Ok(StatusCode::OK)
}

pub async fn delete_book_handler(id: String, db: DB) -> WebResult<impl Reply> {
    db.delete_book(&id).await.map_err(|e| reject::custom(e))?;
    Ok(StatusCode::OK)
}

The BookRequest type is a data object for creating and editing books. Each handler has a DB passed to it via a warp filter (more on that in the next section).

Besides that, the handlers simply call their respective functionalities in the database abstraction and return either a success message in the form of an HTTP 200 OK or, in the case of books_list_handler, a list of the existing books.

In a real-world application, we wouldn’t leak the database object here; we’d transform it into some sort of BookResponse object. But this minimal approach should suffice for the purpose of this tutorial.

Since we’re working from the bottom up, the next and final stage is main, where we’ll put everything together to create a working Rust web application with MongoDB.

Putting it all together

In main, we’ll first define the shared Result types, which were used in handler and db.

type Result<T> = std::result::Result<T, error::Error>;
type WebResult<T> = std::result::Result<T, Rejection>;

These are just to distinguish between internal results and results we send down to clients (WebResult).

The error module, which we won’t go into here, just contains a custom Error enum which implements warp’s Reject trait so we can handle internal errors and return custom errors to clients.

Back in main, the next step is to initialize the database abstraction and define some routes.

#[tokio::main]
async fn main() -> Result<()> {
    let db = DB::init().await?;

    let book = warp::path("book");

    let book_routes = book
        .and(warp::post())
        .and(warp::body::json())
        .and(with_db(db.clone()))
        .and_then(handler::create_book_handler)
        .or(book
            .and(warp::put())
            .and(warp::path::param())
            .and(warp::body::json())
            .and(with_db(db.clone()))
            .and_then(handler::edit_book_handler))
        .or(book
            .and(warp::delete())
            .and(warp::path::param())
            .and(with_db(db.clone()))
            .and_then(handler::delete_book_handler))
        .or(book
            .and(warp::get())
            .and(with_db(db.clone()))
            .and_then(handler::books_list_handler));

    let routes = book_routes.recover(error::handle_rejection);

    println!("Started on port 8080");
    warp::serve(routes).run(([0, 0, 0, 0], 8080)).await;
    Ok(())
}

This part goes exactly how you’d expect it to. First, the database wrapper is initialized. Then, the CRUD routes are defined with their respective HTTP methods, parameters, and body definitions.

The with_db filter passes the MongoDB database wrapper to the handlers.

fn with_db(db: DB) -> impl Filter<Extract = (DB,), Error = Infallible> + Clone {
    warp::any().map(move || db.clone())
}

At the end of main, the actual web server is started.

Now it’s time to test it!

Start a local MongoDB instance using Docker.

docker run -d -p 27017:27017 -v `pwd`/data/db:/data/db --name bookydb mongo

This starts MongoDB on port 27017 with its data directory set to the data/db folder in the directory where the command is executed.

Create a book.

curl -X POST http://localhost:8080/book -d '{"name": "Good Book", "author": "gGeat Author", "num_pages": 500, "tags": ["fun", "exciting"]}' -H "content-type: application/json"

Check to make sure it worked by fetching all books.

curl http://localhost:8080/book

[{"id":"5f19d95800065210009332ef","name":"Good Book","author":"gGeat Author","num_pages":500,"added_at":"2020-07-23T18:39:20.062Z","tags":["fun","exciting"]}]

Now let’s edit the book.

curl -X PUT http://localhost:8080/book/5f19d95800065210009332ef -d '{"name": "Great Book", "author": "Another Great Author", "num_pages": 320, "tags": ["boring", "long"]}' -H "content-type: application/json"

To check that it worked:

curl http://localhost:8080/book

[{"id":"5f19d95800065210009332ef","name":"Great Book","author":"Another Great Author","num_pages":320,"added_at":"2020-07-23T18:39:54.045Z","tags":["boring","long"]}]

Finally, test to make sure the books were successfully deleted.

curl -X DELETE http://localhost:8080/book/5f19d95800065210009332ef
curl http://localhost:8080/book

[]

Fantastic! It works exactly as planned.

The full code for this example can be found on GitHub.

Conclusion

This use case is another shining example of the quickly maturing Rust web ecosystem. The MongoDB driver seems very strong already, providing support for both the tokio and async_std runtimes and providing full synchronous and asynchronous APIs.

The crate recently hit 1.0.0 and is well-documented. Furthermore, the API is intuitive and it’s actively maintained. All in all, a great job by the MongoDB team. I look forward to seeing the first Rust and MongoDB apps in production.

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    LogRocket: Full visibility into production Rust apps

    Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

    LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

    Modernize how you debug your Rust apps — .

    Mario Zupan I'm a software developer originally from Graz but living in Vienna, Austria. I previously worked as a full-stack web developer before quitting my job to work as a freelancer and explore open source. Currently, I work at timeular.

    Leave a Reply