Serde Integration With the MongoDB Rust Driver

This tutorial was written by Jacob Latonis. In this tutorial, we're going to cover some specific portions of the mongodb crate and types available to us in MongoDB's Rust driver. After that, we're going to cover serialization and deserialization, with Serde and some MongoDB specifics. Finally, we're going to build a REST API that allows us the ability to create, read, update, and delete records, building a fully functional API with CRUD operations. Today, we're using a sample set available in MongoDB Atlas's sample geospatial dataset. Learn more about the example datasets that MongoDB provides. Prerequisites Before we get started, there are a few things we need to have set up and running before we can dive-in. First and foremost, you need an Atlas cluster running for your MongoDB database. There is a free tier to try things out. You can find out more about that in the MongoDB Atlas Getting Started documentation. Next, we need to have Rust installed and running on the system. There are many ways to install Rust, but rustup is the recommended way. This will install Rust, Cargo, and all of the helpful utilities related to Rust you will need for this tutorial. Finally, you can load the exact same dataset we will be working with by following the instructions located in the MongoDB Atlas Sample Dataset Documentation. We will be using the geospatial dataset. With all of that out of the way, let's dive into the actual setup! Setting up project dependencies To get started, let's create a project with cargo: cargo init --bin geospatial_api cd geospatial_api/ Once in the directory, the structure is as follows: . ├── Cargo.toml └── src └── main.rs 2 directories, 2 files With our project instantiated, we can now add the mongodb crate and the other needed dependencies to our dependencies: cargo add mongodb cargo add tokio -F full cargo add serde cargo add futures Now, we will see the following added to our Cargo.toml: ───────┬──────────────────────── │ File: Cargo.toml ───────┼──────────────────────── 1 │ [package] 2 │ name = "geospatial_api" 3 │ version = "0.1.0" 4 │ edition = "2021" 5 │ 6 │ [dependencies] 7 | futures = "0.3.31" 8 │ mongodb = "3.2.3" 9 │ serde = "1.0.219" 10 │ tokio = { version = "1.44.1", features = ["full"] } ───────┴──────────────────────── You may see a few differences here: You may see different minor and patch versions for the dependencies listed above. That is completely okay! As long as the major version (the number before the first .) is the same, the behavior should be the same for purposes of this tutorial. You may see the edition field be 2024 if you've recently installed or updated Rust on your system. That is okay too! Connecting to MongoDB Atlas To get started, let's connect our Rust binary to our MongoDB instance. To do this, I am going to use a .env file. If you're unfamiliar with dotenv files, it is a file (normally named .env) in your application's working directory that can store relevant configuration items. My .env file looks like this: ───────┬─────────────────────── │ File: .env ───────┼─────────────────────── 1 │ MONGODB_URI="mongodb+srv://user:@cluster01.jrk3oml.mongodb.net/?retryWrites=true&w=majority&appName=cluster01" ───────┴─────────────────────── To leverage the .env file without having to source it every time via the command-line, we can leverage a crate like dotenvy. To add it to our dependencies, we use cargo add again: cargo add dotenvy Now, we can use it in our binary like so to load our MongoDB connection string into memory and then use it to connect to our MongoDB Atlas cluster and select the database we're working with. In this case, the sample database is named sample_geospatial. use std::env; use mongodb::Client; #[tokio::main] async fn main() -> std::result::Result { dotenvy::dotenv().unwrap(); let mongodb_uri = env::var("MONGODB_URI").unwrap(); let client = Client::with_uri_str(mongodb_uri).await?; let db = client.database("sample_geospatial"); Ok(()) } Once connected, we can attempt to access the collection, named shipwrecks, like this: let collection = db.collection("shipwrecks"); But we will get an error from rust-analyzer: rustc: type annotations needed for `mongodb::Collection` cannot satisfy `_: std::marker::Send` This is due to the code not specifying what kind of record the collection we're attempting to access will contain. Data structs To write our struct, let's dive into an example record and how we can represent that in Rust. An example record looks like this: { "_id": { "$oid": "578f6fa2df35c7fbdbaed8d0" }, "recrd": "", "vesslterms": "", "feature_type": "Wrecks - Visible", "chart": "US,US,reprt,L-1-2015", "latdec": { "$numberDouble": "9.3549722" }, "londec": { "$numberDouble": "-79.908

May 6, 2025 - 19:20
 0
Serde Integration With the MongoDB Rust Driver

This tutorial was written by Jacob Latonis.

In this tutorial, we're going to cover some specific portions of the mongodb crate and types available to us in MongoDB's Rust driver. After that, we're going to cover serialization and deserialization, with Serde and some MongoDB specifics. Finally, we're going to build a REST API that allows us the ability to create, read, update, and delete records, building a fully functional API with CRUD operations.

Today, we're using a sample set available in MongoDB Atlas's sample geospatial dataset. Learn more about the example datasets that MongoDB provides.

Prerequisites

Before we get started, there are a few things we need to have set up and running before we can dive-in.

First and foremost, you need an Atlas cluster running for your MongoDB database. There is a free tier to try things out. You can find out more about that in the MongoDB Atlas Getting Started documentation.

Next, we need to have Rust installed and running on the system. There are many ways to install Rust, but rustup is the recommended way. This will install Rust, Cargo, and all of the helpful utilities related to Rust you will need for this tutorial.

Finally, you can load the exact same dataset we will be working with by following the instructions located in the MongoDB Atlas Sample Dataset Documentation. We will be using the geospatial dataset.

With all of that out of the way, let's dive into the actual setup!

Setting up project dependencies

To get started, let's create a project with cargo:

cargo init --bin geospatial_api
cd geospatial_api/

Once in the directory, the structure is as follows:

.
├── Cargo.toml
└── src
    └── main.rs

2 directories, 2 files

With our project instantiated, we can now add the mongodb crate and the other needed dependencies to our dependencies:

cargo add mongodb
cargo add tokio -F full
cargo add serde 
cargo add futures

Now, we will see the following added to our Cargo.toml:

───────┬────────────────────────
       │ File: Cargo.toml
───────┼────────────────────────
   1   │ [package]
   2   │ name = "geospatial_api"
   3   │ version = "0.1.0"
   4   │ edition = "2021"
   5   │
   6   │ [dependencies]
   7   | futures = "0.3.31"
   8   │ mongodb = "3.2.3" 
   9   │ serde = "1.0.219"
  10   │ tokio = { version = "1.44.1", features = ["full"] }
───────┴────────────────────────

You may see a few differences here:

  • You may see different minor and patch versions for the dependencies listed above. That is completely okay! As long as the major version (the number before the first .) is the same, the behavior should be the same for purposes of this tutorial.
  • You may see the edition field be 2024 if you've recently installed or updated Rust on your system. That is okay too!

Connecting to MongoDB Atlas

To get started, let's connect our Rust binary to our MongoDB instance. To do this, I am going to use a .env file. If you're unfamiliar with dotenv files, it is a file (normally named .env) in your application's working directory that can store relevant configuration items.

My .env file looks like this:

───────┬───────────────────────
       │ File: .env
───────┼───────────────────────
   1   │ MONGODB_URI="mongodb+srv://user:@cluster01.jrk3oml.mongodb.net/?retryWrites=true&w=majority&appName=cluster01"
───────┴───────────────────────

To leverage the .env file without having to source it every time via the command-line, we can leverage a crate like dotenvy.

To add it to our dependencies, we use cargo add again:

cargo add dotenvy

Now, we can use it in our binary like so to load our MongoDB connection string into memory and then use it to connect to our MongoDB Atlas cluster and select the database we're working with. In this case, the sample database is named sample_geospatial.

use std::env;

use mongodb::Client;

#[tokio::main]
async fn main() -> std::result::Result<(), mongodb::error::Error> {
    dotenvy::dotenv().unwrap();
    let mongodb_uri = env::var("MONGODB_URI").unwrap();
    let client = Client::with_uri_str(mongodb_uri).await?;
    let db = client.database("sample_geospatial");

    Ok(())
}

Once connected, we can attempt to access the collection, named shipwrecks, like this:

let collection = db.collection("shipwrecks");

But we will get an error from rust-analyzer:

rustc: type annotations needed for `mongodb::Collection<_>`
cannot satisfy `_: std::marker::Send`

This is due to the code not specifying what kind of record the collection we're attempting to access will contain.

Data structs

To write our struct, let's dive into an example record and how we can represent that in Rust.

An example record looks like this:

{
  "_id": { "$oid": "578f6fa2df35c7fbdbaed8d0" },
  "recrd": "",
  "vesslterms": "",
  "feature_type": "Wrecks - Visible",
  "chart": "US,US,reprt,L-1-2015",
  "latdec": { "$numberDouble": "9.3549722" },
  "londec": { "$numberDouble": "-79.9084167" },
  "gp_quality": "",
  "depth": { "$numberInt": "0" },
  "sounding_type": "",
  "history": "",
  "quasou": "",
  "watlev": "always dry",
  "coordinates": [
    { "$numberDouble": "-79.9084167" },
    { "$numberDouble": "9.3549722" }
  ]
}

To turn this into a struct in Rust, a few things are of note:

  1. The object ID is a nested item.
  2. latdec and londec are both of type double.
  3. Coordinates is a list of double numbers.

To account for this, we can leverage some types from the mongodb crate.

ObjectID

Generally, we can use the ObjectId type from the mongodb crate. However, we may want to wrap it in an Option type when we define our struct later, as we may want to have a documentation representation without an oid, such as before one is inserted.

use mongodb::bson::oid::ObjectId;

let id: ObjectId;

let o_id: Option<ObjectId>;

Double

let num: f64;

If you look closely, you will also notice we have a vector of size 2 for coordinates.

I think a tuple works well here for that:

let t: (f64, f64);

String

let s: String;

Now that we've covered the unique types we would encounter with this dataset, it is time to define a struct that we can leverage for the geospatial records.

struct Shipwreck {
    _id: Option<ObjectId>,
    recrd: String,
    vesslterms: String,
    feature_type: String,
    chart: String,
    latdec: f64,
    londec: f64,
    gp_quality: String,
    depth: f64,
    sounding_type: String,
    history: String,
    quasou: String,
    watlev: String,
    coordinates: (f64, f64)
}

Next, we need to derive some traits via the derive macro that will make our lives a lot easier for serialization and deserialization between MongoDB and our Rust binary.

First, we need to bring serde's traits into scope like so:

use serde::{Serialize, Deserialize};

Then, we can leverage the derive macro:

#[derive(Serialize, Deserialize, Debug)]

If you're curious about Serialize, Deserialize, and Debug, we will dive into those in-depth a bit later. For now, let's add them above our struct declaration.

This will leave you with a finished struct like so:

#[derive(Serialize, Deserialize, Debug)]
struct Shipwreck {
    _id: Option<ObjectId>,
    recrd: String,
    vesslterms: String,
    feature_type: String,
    chart: String,
    latdec: f64,
    londec: f64,
    gp_quality: String,
    depth: i64,
    sounding_type: String,
    history: String,
    quasou: String,
    watlev: String,
    coordinates: (f64, f64)
}

With this struct defined, we can add it in our main.rs toward the top, beneath our use statements, outside of any functions.

At this point, we can now update the initialization of our collection from earlier to be like so:

let collection = db.collection::<Shipwreck>("shipwrecks");

CRUD operations

Create

To create and insert a new shipwreck record, we can use the insert_one method or the insert_many method, depending on how many records we have to insert.

let new_shipwreck = Shipwreck {
    _id: Some(ObjectId::new()),
    recrd: "Sample Record".to_string(),
    vesslterms: "Sample Vessel".to_string(),
    feature_type: "Wreck".to_string(),
    chart: "Sample Chart".to_string(),
    latdec: 42.0,
    londec: -71.0,
    gp_quality: "Good".to_string(),
    depth: 100.0,
    sounding_type: "Echo".to_string(),
    history: "Discovered in 2025".to_string(),
    quasou: "Unknown".to_string(),
    watlev: "Always under water".to_string(),
    coordinates: (42.0, -71.0),
};

let result = collection.insert_one(new_shipwreck).await?;

Read

Before we dive into querying documents, we need to add a use statement to the top of our main.rs to leverage the doc! macro, resulting in our use mongodb statement looking like so:

use mongodb::{
    bson::{doc, oid::ObjectId},
    Client,
};

To try and receive a shipwreck record, we can now query the collection like so:

let collection = db.collection::<Shipwreck>("shipwrecks");

let wreck = collection.find_one(doc!{}).await?;
dbg!(wreck);

You'll notice we're able to call dbg!() and pass it the wreck record, and that is because we derived the Debug trait earlier. This allows for human readable representations of the Shipwreck struct to be displayed like so:

[src/main.rs:15:5] wreck = Some(
    Shipwreck {
        _id: Some(
            ObjectId(
                "578f6fa2df35c7fbdbaed8c4",
            ),
        ),
        recrd: "",
        vesslterms: "",
        feature_type: "Wrecks - Visible",
        chart: "US,U1,graph,DNC H1409860",
        latdec: 9.3547792,
        londec: -79.9081268,
        gp_quality: "",
        depth: 0,
        sounding_type: "",
        history: "",
        quasou: "",
        watlev: "always dry",
        coordinates: (
            -79.9081268,
            9.3547792,
        ),
    },
)

To try and receive every shipwreck record, it is a similar query:

let mut wrecks = collection.find(doc!{}).await?;

while wrecks.advance().await? {
    dbg!(wrecks.deserialize_current()?);
}

Update

There are two ways we can approach updating a record:

  • Setting new fields in an existing document
  • Replacing a document

When setting new fields, we can leverage the $set operator.

let query = doc!{"watlev": "always dry"};
let update = doc!{"$set" : {"dry": true}};

let res = collection.update_one(query, update).await?;
dbg!(res);

One thing of note in the example above: Using update_one() will result in the first result of the query being updated, not the total N records that matched the query.

Doing so will bring a result like so:

[src/main.rs:50:5] res = UpdateResult {
    matched_count: 1,
    modified_count: 1,
    upserted_id: None,
}

Replacing a document is nearly as simple. However, it requires a full document instead of a subset of fields to replace or set.

let query = doc!{"watlev": "always dry"};
let w = Shipwreck {
    _id: None,
    recrd: "".to_string(),
    vesslterms: "shiny".to_string(),
    feature_type: "Wrecks - Visible".to_string(),
    chart: "US,Example1".to_string(),
    latdec: 0.3547792,
    londec: -45.3547792,
    gp_quality: "".to_string(),
    depth: 10.0,
    sounding_type: "".to_string(),
    history: "".to_string(),
    quasou: "".to_string(),
    watlev: "".to_string(),
    coordinates: (-45.3547792, 0.3547792),
};

let res = collection.replace_one(query, w).await?;
dbg!(res);

Doing so will bring a very similar result to above:

[src/main.rs:71:5] res = UpdateResult {
    matched_count: 1,
    modified_count: 1,
    upserted_id: None,
}

I would be remiss if I didn't mention $upsert when discussing update methods! If you're unfamiliar with the $upsert operator, $upsert is a wonderful combination between insert and update. If your query finds a document in the collection, the update is performed as expected. However, if the query does not deliver a document from the collection, $upsert will insert a new document into the collection according to the update/replacement criteria you defined in your update document.

Delete

If there are some documents you need to clean up, there's a method for that too.

It is fairly straightforward. All you need is a filter query, and then pass that filter to the delete_one or delete_many methods. Be careful with delete_many, as it will indeed remove all the documents that match the filter query passed.

For now, I am laying out a simple use with delete_one:

let query = doc! { "watlev": "always dry" };

let delete_result = collection.delete_one(query).await?;

REST API

Now that we've got the data model in Rust sorted out, we can focus on getting the API up and running. For this use-case, I am going to be using the actix-web crate.

To add this as a dependency, we can use cargo add again:

cargo add actix-web

Once successful, we can start to lay out some of our structures needed to leverage the mongodb crate and our data in the HTTP endpoints. To start, I am going to define an application state structure that will be sent to each handler:

#[derive(Clone)]
struct AppState {
    client: Client,
    db_name: String,
    collection_name: String
}

This will allow us to leverage actix-web's ability to have data shared amongst handlers. In this case, we will be passing our mongodb client, the database name, and the relevant collection name. You could extend this to share more things, but this set accomplishes our use-cases for now. With that structure defined, we can now modify the main function to instantiate the AppState structure and its underlying values.

let mongodb_uri = env::var("MONGODB_URI").unwrap();
let client = Client::with_uri_str(mongodb_uri).await?;
let db_name = "sample_geospatial";
let collection_name = "shipwrecks";

let app_state = AppState {
    client: client.clone(),
    db_name: db_name.into(),
    collection_name: collection_name.into()
};

Next, we will add some import statements to bring a couple of things into scope that will be used later:

use actix_web::{web, App, HttpResponse, HttpServer, Responder};

Now that the application can pass around some of our data, spinning up the actual HTTP server is straightforward:

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

There is a bit to unpack above:

  • Creating the HTTP server
  • Creating a new actix-web App
  • Passing our new structure to the App
  • Adding the /ping route
  • Binding it to localhost:8080

With our webserver defined, we can now define a simple handler:

async fn ping() -> impl Responder {
    HttpResponse::Ok().body("pong!")
}

Now, when someone calls the /ping route on our API, we will respond with pong!:

└─[$] curl localhost:8080/ping | bat                                                                                                                                                                              [10:06:25]
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100     5  100     5    0     0   5995      0 --:--:-- --:--:-- --:--:--  5000
───────┬────────────
       │ STDIN
───────┼────────────
   1   │ pong!
───────┴────────────

This is the basic structure of how we will start to extend our REST API, except we will be leveraging mongodb and serde to handle proper JSON responses that automatically serialize and deserialize from JSON to Rust data and more! The mongodb and actix-web crates will make this pretty straightforward and quite fun.

Testing these, we can leverage curl and jq to try out our endpoints. I'll include an example curl request and the appropriate response from our API.

Create

A general pattern for creation of a record via API is accepting a POST request somewhere and receiving the data, validating it, and inserting it into the database. Luckily, for us, serde and mongodb handle a lot of the validation due to how we've defined our structs up above. For creation of shipwrecks, I think a URI path of /shipwrecks and accepting a POST to it makes sense for our use-case.

First, let’s get our handler defined, which accepts our application state (the mongodb client, db name, and collection name) and the body of the POST request, which would be coming in as JSON:

async fn create_shipwreck(
    state: web::Data<AppState>,
    shipwreck: web::Json<Shipwreck>,
) -> Result<HttpResponse, actix_web::Error> {
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection(state.collection_name.as_str());
    let result = collection
        .insert_one(shipwreck.into_inner())
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;
    if let Some(new_id) = result.inserted_id.as_object_id() {
        Ok(HttpResponse::Created().json(doc! { "_id": new_id.to_hex() }))
    } else {
        Ok(HttpResponse::InternalServerError().finish())
    }
}

Now that we have the create endpoint defined for handling inserting new shipwrecks into our database, let's extend our list of routes defined in main() so the actix-web application knows how to handle the request:

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
            .route("/shipwrecks", web::post().to(create_shipwreck))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

Testing the endpoint:

curl -X POST -H "Content-Type: application/json" -d '{
    "_id": "67f4127ba17aace127777ae2",
    "recrd": "New Record",
    "vesslterms": "Test Ship",
    "feature_type": "Wreck - Submerged",
    "chart": "Test Chart",
    "latdec": 10.123,
    "londec": -80.456,
    "gp_quality": "Good",
    "depth": 25,
    "sounding_type": "Sounding",
    "history": "Test History",
    "quasou": "Unsafe",
    "watlev": "always under water",
    "coordinates": [-80.456, 10.123]
}' http://127.0.0.1:8080/shipwrecks | jq
{
  "_id": "67f4127ba17aace127777ae2"
}

Read

As we've already defined how to query for a single document in our MongoDB cluster in Atlas, we can adapt that code fairly easily to work with our API and actix-web.

We can start by defining a new handler, where most of it is initializing our collection from the mongodb client. Next, we will perform our query. Finally, we can then use map_err to have a cleaner function without unnecessary unwrap() or match statements and then return our result.

A common pattern for REST APIs and reading documents is having the category—in our case, shipwrecks—that accepts a general GET on the category for listing all of them and then accepts a query for a particular ID as well. We can organize it to be like so:

  • GET /shipwrecks
  • GET /shipwrecks/{id}

Let’s focus on retrieving a singular record via ID. We take in the ObjectID as a string, convert it from a string to a valid ObjectID, and then query for the document in the database.

async fn get_shipwreck(
    state: web::Data<AppState>,
    path: web::Path<String>,
) -> Result<HttpResponse, actix_web::Error> {
    let id_str = path.into_inner();
    let object_id =
        ObjectId::parse_str(&id_str).map_err(|_| actix_web::error::ErrorBadRequest("Invalid ID"))?;
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection::<Shipwreck>(state.collection_name.as_str());
    let wreck = collection
        .find_one(doc! { "_id": object_id })
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;

    match wreck {
        Some(w) => Ok(HttpResponse::Ok().json(w)),
        None => {
            Ok(HttpResponse::NotFound().body(format!("Shipwreck with ID {} not found", id_str)))
        }
    }
}

With the handler defined, we need to extend our routing list to accommodate our newly defined handler that will accept a GET and an ID:

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
            .route("/shipwrecks", web::post().to(create_shipwreck))
            .route("/shipwrecks/{id}", web::get().to(get_shipwreck))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

You'll begin to notice a pattern here for implementation: Implement the handler with the behavior we want, and then extend our list of routes to point the application to the handler to enable that behavior.

Now that we have a singular get, we can also implement the handler for a GET on all shipwrecks to receive a list of them.

use futures::stream::TryStreamExt;

async fn get_all_shipwrecks(state: web::Data<AppState>) -> Result<HttpResponse, actix_web::Error> {
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection(state.collection_name.as_str());
    let cursor = collection
        .find(doc! {})
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;
    let wrecks: Vec<Shipwreck> = cursor
        .try_collect()
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;
    Ok(HttpResponse::Ok().json(wrecks))
}

As discussed above, we can implement this GET on /shipwrecks, without the {id} in the path.

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
            .route("/shipwrecks", web::post().to(create_shipwreck))
            .route("/shipwrecks", web::get().to(get_all_shipwrecks))
            .route("/shipwrecks/{id}", web::get().to(get_shipwreck))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

To test these endpoints, we can again leverage curl:

curl localhost:8080/shipwrecks | jq

This brings about a list of all of our shipwreck records currently in our database.

[
 {
    "_id": {
      "$oid": "67f17a4beaff0a58a727cedf"
    },
    "recrd": "",
    "vesslterms": "shiny",
    "feature_type": "Wrecks - Visible",
    "chart": "US,Example1",
    "latdec": 0.3547792,
    "londec": -45.3547792,
    "gp_quality": "",
    "depth": 10.0,
    "sounding_type": "",
    "history": "",
    "quasou": "",
    "watlev": "",
    "coordinates": [
      -45.3547792,
      0.3547792
    ]
  },
  {
    "_id": {
      "$oid": "67f4123ea17aace127777ae1"
    },
    "recrd": "New Record",
    "vesslterms": "Test Ship",
    "feature_type": "Wreck - Submerged",
    "chart": "Test Chart",
    "latdec": 10.123,
    "londec": -80.456,
    "gp_quality": "Good",
    "depth": 25.0,
    "sounding_type": "Sounding",
    "history": "Test History",
    "quasou": "Unsafe",
    "watlev": "always under water",
    "coordinates": [
      -80.456,
      10.123
    ]
  },
  {
    "_id": {
      "$oid": "67f4127ba17aace127777ae2"
    },
    "recrd": "New Record",
    "vesslterms": "Test Ship",
    "feature_type": "Wreck - Submerged",
    "chart": "Test Chart",
    "latdec": 10.123,
    "londec": -80.456,
    "gp_quality": "Good",
    "depth": 25.0,
    "sounding_type": "Sounding",
    "history": "Test History",
    "quasou": "Unsafe",
    "watlev": "always under water",
    "coordinates": [
      -80.456,
      10.123
    ]
  }
  [... a lot more results]
]

Update

To update a record, we need a handler to handle a record coming in via JSON data as well as the replacement in our database. I think PUTing to the URI path of /shipwrecks/{id} is a fine place to implement the update or replacement of a record.

There are a few things we can watch out for here:

  • Validation that the ID passed in the URI is indeed a valid ObjectID.
  • The ObjectID is valid but not present in the database.
  • The update was successful.
async fn update_shipwreck(
    state: web::Data<AppState>,
    path: web::Path<String>,
    shipwreck: web::Json<Shipwreck>,
) -> Result<HttpResponse, actix_web::Error> {
    let id_str = path.into_inner();
    let object_id =
        ObjectId::parse_str(&id_str).map_err(|_| actix_web::error::ErrorBadRequest("Invalid ID"))?;
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection::<Shipwreck>(state.collection_name.as_str());

    let update_result = collection
        .replace_one(doc! { "_id": object_id }, shipwreck.into_inner())
        .await
        .map_err(|_| actix_web::error::ErrorInternalServerError("Unable to update document"))?;

    if update_result.matched_count > 0 {
        Ok(HttpResponse::Ok().json(doc! { "modified_count": update_result.modified_count as i64 }))
    } else {
        Ok(HttpResponse::NotFound().body(format!("Shipwreck with ID {} not found", id_str)))
    }
}

With the update handler defined, we need to extend the routing in main():

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
            .route("/shipwrecks", web::post().to(create_shipwreck))
            .route("/shipwrecks", web::get().to(get_all_shipwrecks))
            .route("/shipwrecks/{id}", web::get().to(get_shipwreck))
            .route("/shipwrecks/{id}", web::put().to(update_shipwreck))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

To test this endpoint, I wrote up a curl command which shows the resulting output below it.

curl -X PUT -H "Content-Type: application/json" -d '{
    "recrd": "Updated Record",
    "vesslterms": "Modified Ship",
    "feature_type": "Wreck - Visible",
    "chart": "Updated Chart",
    "latdec": 11.456,
    "londec": -81.789,
    "gp_quality": "Excellent",
    "depth": 10,
    "sounding_type": "New Sounding",
    "history": "Updated History",
    "quasou": "Safe",
    "watlev": "always dry",
    "coordinates": [-81.789, 11.456]
}' http://127.0.0.1:8080/shipwrecks/ | jq
{
  "modified_count": 1
}

Delete

Deleting an object is very straightforward. We take the same path as we have above with GET and PUT for reading and updating, but we accept DELETE now.

The caveat we watch out for now is similar to above:

  • A valid ObjectID is passed.
  • The document exists.
  • Deletion succeeds.
async fn delete_shipwreck(
    state: web::Data<AppState>,
    path: web::Path<String>,
) -> Result<HttpResponse, actix_web::Error> {
    let id_str = path.into_inner();
    let object_id =
        ObjectId::parse_str(&id_str).map_err(|_| actix_web::error::ErrorBadRequest("Invalid ID"))?;
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection::<Shipwreck>(state.collection_name.as_str());
    let delete_result = collection
        .delete_one(doc! { "_id": object_id })
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;

    if delete_result.deleted_count > 0 {
        Ok(HttpResponse::NoContent().finish())
    } else {
        Ok(HttpResponse::NotFound().body(format!("Shipwreck with ID {} not found", id_str)))
    }
}

With our handler defined, we need to add it to our list of handlers in main():

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .route("/ping", web::get().to(ping))
            .route("/shipwrecks", web::post().to(create_shipwreck))
            .route("/shipwrecks", web::get().to(get_all_shipwrecks))
            .route("/shipwrecks/{id}", web::get().to(get_shipwreck))
            .route("/shipwrecks/{id}", web::put().to(update_shipwreck))
            .route("/shipwrecks/{id}", web::delete().to(delete_shipwreck))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

The curl example here is quite small. We just need to make sure the request sends as a DELETE request to an ObjectID that exists in the database.

curl -X DELETE http://127.0.0.1:8080/shipwrecks/ | jq
{
  "status": "successfully deleted 578f6fa2df35c7fbdbaed8c6"
}

Serde attributes

Now that we've got the general structure of our API and the structure of the data model and its accompanying code in the Rust application laid out, let's take a look at how we can leverage serde attributes to clean up our output and change some things we may not want from making its way out in the default form. If you want to read about serde field attributes more before diving into our own examples, you can find more in the Serde documentation.

ObjectID to string

As I'm sure you've seen in the example output, we still encode an ObjectID in the JSON output where it’s nested, like so:

"_id": {
  "$oid": "67f4127ba17aace127777ae2"
}

If we want to change this to deserialize into a hex-string instead of the nested ObjectID BSON representation, we can do the following in a struct by leveraging the deserialize_with attribute:

use mongodb::bson::serde_helpers::deserialize_hex_string_from_object_id;

[...]

#[derive(Serialize, Deserialize, Debug)]
struct Shipwreck {
    #[serde(deserialize_with = "deserialize_hex_string_from_object_id")]
    _id: String,
    [...]
    }

This delivers us a result with a string instead of the nested bson representation:

{
  "_id": "67f4127ba17aace127777ae2"
}

String to ObjectID

The serialization for string to ObjectID works the opposite way as the ObjectID to string deserialization we did earlier.

Building on top of the earlier shipwreck struct definition, we can add a different option for serde: serialize_with.

use mongodb::bson::serde_helpers::{deserialize_hex_string_from_object_id, serialize_hex_string_as_object_id};

#[derive(Serialize, Deserialize, Debug)]
struct Shipwreck {
    #[serde(serialize_with = "serialize_hex_string_as_object_id")]
    _id: String,
    [...]
}

Now, we can feed an object ID in as a string, and the helper function serialize_hex_string_as_object_id will convert it for us from string to ObjectID.

curl -X POST -H "Content-Type: application/json" -d '{         
    "_id": "67f4127ba17aace127777ae2",
    "recrd": "New Record",
    "vesslterms": "Test Ship",
    "feature_type": "Wreck - Submerged",
    "chart": "Test Chart",
    "latdec": 10.123,
    "londec": -80.456,
    "gp_quality": "Good",
    "depth": 25,
    "sounding_type": "Sounding",
    "history": "Test History",
    "quasou": "Unsafe",
    "watlev": "always under water",
    "coordinates": [-80.456, 10.123]
}' http://127.0.0.1:8080/shipwrecks | jq

Leveraging a Serde helper module

You'll notice in the two previous examples of leveraging custom serde methods that we specified serialize_hex_string_as_object_id and deserialize_hex_string_from_object_id. These are actually both from the same module, and some modules can be used instead of specifying the exact functions, to get functionality from both serialize and deserialize in a bit of a cleaner way. To do this, we can do something like the following:

use mongodb::bson::serde_helpers::hex_string_as_object_id;

#[derive(Serialize, Deserialize, Debug)]
struct Shipwreck {
    #[serde(with = "hex_string_as_object_id")]
    _id: String,
    [...]
}

Separating it out

By now, you may have noticed that there are lots of attributes and ways to customize what happens to your Rust struct when exporting to either JSON or BSON.

As such, you may have run into instances where what you want to happen to your JSON is not what you want to happen when it gets serialized into BSON for insertion into your MongoDB database.

As such, it may make sense to break out your structures into two different structs: an API response and a database record.

Database record

In the database record, I chose to keep the _id field named as is, skip serializing it if it is not present, which allows for MongoDB to create the _id upon insertion, and not skip serializing any fields.

#[derive(Serialize, Deserialize, Debug)]
struct ShipwreckRecord {
    #[serde(skip_serializing_if = "Option::is_none")]
    _id: Option<ObjectId>,
    recrd: String,
    vesslterms: String,
    feature_type: String,
    chart: String,
    latdec: f64,
    londec: f64,
    gp_quality: String,
    depth: f64,
    sounding_type: String,
    history: String,
    quasou: String,
    watlev: String,
    coordinates: (f64, f64),
}

API response

For my API response, which is what will be serialized into JSON, I will rename the _id field to id. You may notice that it is an ObjectID in ShipwreckRecord but a String in ShipwreckApiResponse. We will take care of this conversion by implementing the Into trait.

#[derive(Serialize, Deserialize, Debug)]
struct ShipwreckApiResponse {
    #[serde(rename = "id")]
    _id: String,
    recrd: String,
    vesslterms: String,
    feature_type: String,
    chart: String,
    latdec: f64,
    londec: f64,
    gp_quality: String,
    depth: f64,
    sounding_type: String,
    history: String,
    quasou: String,
    watlev: String,
}

Implementing From

As promised above, let's implement the From trait for converting a ShipwreckRecord into a ShipwreckApiResponse. Most of it is plug and play and allows for us to just carry the values over, but I wanted to keep the ObjectID as a hex string for my API response, which is why I take the _id, check if it is valid in hex, and use it or a default No ID placeholder before placing it in the id field of ShipwreckApiResponse.

impl From<ShipwreckRecord> for ShipwreckApiResponse {
    fn from(record: ShipwreckRecord) -> Self {
        ShipwreckApiResponse {
            _id: record
                ._id
                .map(|id| id.to_hex())
                .unwrap_or_else(|| "No ID".to_string()),
            recrd: record.recrd,
            vesslterms: record.vesslterms,
            feature_type: record.feature_type,
            chart: record.chart,
            latdec: record.latdec,
            londec: record.londec,
            gp_quality: record.gp_quality,
            depth: record.depth,
            sounding_type: record.sounding_type,
            history: record.history,
            quasou: record.quasou,
            watlev: record.watlev,
        }
    }
}

With this, we can now update our function signatures. As an example, I am now going to update get_shipwreck. We will need to replace the previous instances of Shipwreck with ShipwreckRecord.

Our collection instantiation now looks like this:

let collection = state
.client
.database(state.db_name.as_str())
.collection::<ShipwreckRecord>(state.collection_name.as_str());

We can then change our response to return a ShipwreckApiResponse instead of a ShipwreckRecord, leveraging the From trait show above:

Ok(HttpResponse::Ok().json(ShipwreckApiResponse::from(w)))

Pulling it all together gives us this updated handler:

async fn get_shipwreck(
    state: web::Data<AppState>,
    path: web::Path<String>,
) -> Result<HttpResponse, actix_web::Error> {
    let id_str = path.into_inner();
    let object_id =
        ObjectId::parse_str(&id_str).map_err(|_| actix_web::error::ErrorBadRequest("Invalid ID"))?;
    let collection = state
        .client
        .database(state.db_name.as_str())
        .collection::<ShipwreckRecord>(state.collection_name.as_str());
    let wreck = collection
        .find_one(doc! { "_id": object_id })
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;

    match wreck {
        Some(w) => Ok(HttpResponse::Ok().json(ShipwreckApiResponse::from(w))),
        None => {
            Ok(HttpResponse::NotFound().body(format!("Shipwreck with ID {} not found", id_str)))
        }
    }
}

Testing out the endpoint brings us this result:

{
  "id": "578f6fa2df35c7fbdbaed8c8",
  "recrd": "",
  "vesslterms": "",
  "feature_type": "Wrecks - Submerged, dangerous",
  "chart": "US,U1,graph,DNC H1409860",
  "latdec": 9.3418808,
  "londec": -79.9103851,
  "gp_quality": "",
  "depth": 0.0,
  "sounding_type": "",
  "history": "",
  "quasou": "depth unknown",
  "watlev": "always under water/submerged"
}

You may also notice the serde attributes working as intended, as well as the From trait working as intended. The _id field has been converted from an ObjectID into a String, and it has also been renamed to id.

Custom deserialization

As there is custom serialization, we may run into use cases for custom deserialization, as well. For example, if you have a string from an API or another data source that comes in as shipname|color|numberofwindows, you likely want to store that in your database as three separate fields, so they can be stored and searched or indexed properly.

To implement custom deserialization, we can implement the Deserialize trait ourselves.

I defined a new struct called Qualities and added it into our ShipwreckRecord struct:

#[derive(Serialize, Deserialize, Debug)]
struct Qualities {
    name: String,
    color: String,
    windows: usize,
}

#[derive(Serialize, Deserialize, Debug)]
struct ShipwreckRecord {
    #[serde(skip_serializing_if = "Option::is_none")]
    _id: Option<ObjectId>,
    recrd: String,
    vesslterms: String,
    feature_type: String,
    chart: String,
    latdec: f64,
    londec: f64,
    gp_quality: String,
    depth: f64,
    sounding_type: String,
    history: String,
    quasou: String,
    watlev: String,
    coordinates: (f64, f64),
    qualities: Qualities
}

To implement a custom deserializer, we can leverage the deserialize_with attribute and place it above the qualities entry in the ShipwreckRecord struct.

#[serde(deserialize_with = "deserialize_qualities")]
qualities: Qualities

Now, let's implement the custom Deserialize function we named deserialize_coordinates. We need to add the following to our use statements at the top of the file:

use serde::Deserializer;

Now that Deserializer is in scope, we can proceed with writing the function, where we specify the exact logic on how to deserialize the struct from the single string passed in under the key coordinates with the custom format of shipname|color|numberofwindows:

fn deserialize_qualities<'de, D>(deserializer: D) -> Result<Qualities, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    let values: Vec<&str> = s.split("|").collect();

    if values.len() != 3 {
        return Err(serde::de::Error::custom("Invalid formatted coordinates value"))
    }

    let windows_count = values[2].parse();

    if windows_count.is_err() {
        return Err(serde::de::Error::custom("Invalid formatted value for number of windows on the ship"))
    }

    Ok(Qualities {name: values[0].into(), color: values[1].into(), windows: windows_count.unwrap()})
}

The function above will take in the following:

old_reliable|brown|42

And it will produce the struct:

Qualities {
    shipname: "Old_reliable",
    color: "brown",
    windows: 42,
}

This is just one example of custom deserialization. The possibilities are quite limitless, but I wanted to provide a concrete example that may be useful for setting up your own custom Deserializer implementations.

Custom serialization

There may be some circumstances where data is in a format that serde cannot handle gracefully when serializing, like our previous example of having three values separated by the pipe character, |, in a string.

For this, we need to implement Serialize ourselves. We can do that by implementing Serialize for a given struct or with the serialize_with function, similar to what we did above with deserialize_with. For this example, I am going to implement Serialize for the struct itself.

Before we implement the Serializer, we need to add one more use statement at the top of our file:

use serde::ser::Serializer;

WIth that added, we can begin. Like any trait in Rust, we can do impl for to get started:

impl Serialize for Qualities {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
      where
          S: Serializer,
      {
          serializer.serialize_str(format!("{}|{}|{}", self.name, self.color, self.windows).as_str())
      }
}

As such, now, when our struct is serialized, it will be serialized back into the format like it was first given to us: shipname|color|numberofwindows.

Wrapping up

Look how far we’ve come! We covered quite a bit in this tutorial. First, we utilized a dataset from MongoDB in MongoDB Atlas. Second, we implemented CRUD operations in Rust. Third, we wrote a REST API that leveraged those CRUD operations. Finally, we applied custom serialization and deserialization methods and traits for our custom structs.

We just scratched the surface of what is possible with Rust, MongoDB, and Serde today. Just as a quick reminder, great documentation exists for the MongoDB crate, Serde, and actix-web.

View the GitHub repository and finished product.

Happy building!