DEV Community

loading...
Cover image for Build an API in Rust (Part 2)

Build an API in Rust (Part 2)

NARUHODO
Software engineer during the week and apprentice blogger during the weekend. I like JS and Rust. 🤓
Originally published at naruhodo.dev ・6 min read

Welcome to the second part of the guide on how to build an API in Rust!

To follow this guide you will need to have the code from the first part. If you haven't checked it out yet, please do!

This time I will explain how to connect the API to a MongoDB. I will create two endpoints, one to add data in the database and another one to retrieve the data from the database.

I will put all the code in the same file (src/main.rs). Of course in a real situation you should split the code into multiple files/directories and separate the logic between controllers and services.

Prerequisites

To be able to follow the guide, you will need to have Rust and Cargo installed.
You will also need to have a MongoDB running. If you're not sure how to install MongoDB, there are 3 ways I can think of:

  • You can either install MongoDB on your machine. Follow the official documentation to do so.
  • If you have Docker, you can run a MongoDB container. Check out the mongo image documentation. This is the command I use to run the mongo container:
docker run -d -p 27017:27017 --name mongo mongo
Enter fullscreen mode Exit fullscreen mode
  • If you prefer to avoid installing anything (MongoDB/Docker), you can create a free cluster on MongoDB Atlas.

Set up the environment

We need to create a .env file to store the MongoDB connection string safely. If you're using Git, make sure to add .env in your .gitignore. You should never commit a .env file.

Open the project from part 1 and create the .env file

touch .env
Enter fullscreen mode Exit fullscreen mode

Inside the .env file, add the following

MONGODB_URI=mongodb://localhost:27017
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the connection string with the correct one depending on how you're running MongoDB.

It is good practice to have a .env.example file (that one should be committed) so that when someone pulls the project for the first time they have an example on how to set up the .env file.

Create a .env.example file

touch .env.example
Enter fullscreen mode Exit fullscreen mode

Add the following

MONGODB_URI= ** MongoDB URI (e.g. mongodb://localhost:27017)
Enter fullscreen mode Exit fullscreen mode

Add the new dependencies

We need to add 2 new dependencies. dotenv and mongodb.

dotenv is a crate that allows us to use a .env file. It adds the variables from the file to the environment variables.

mongodb is the official MongoDB Rust driver. It allows us to interact with the database.

Update the Cargo.toml file with the two dependencies. The updated dependencies should be like this

[dependencies]
tide = "0.16"
async-std = { version = "1", features = ["attributes"] }
serde = { version = "1", features = ["derive"] }
dotenv = "0.15"
mongodb = { version = "1", features = ["async-std-runtime"], default-features = false }
Enter fullscreen mode Exit fullscreen mode

Because we use the async-std runtime, we need to disable the default features of the mongodb crate. By default it uses tokio. Then we need to add the async-std-runtime feature to use the async-std runtime.

Let's code

We need to make some modifications to the code. First update the imports with the following

use async_std::stream::StreamExt;
use dotenv::dotenv;
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use std::env;
use tide::{Body, Request, Response, StatusCode};
Enter fullscreen mode Exit fullscreen mode

These are all the module items we will use in this walk-through.

Remember last time we set up an empty Tide State? This time we will use the State to pass the database connection to the controllers. We need to update the State struct like this

#[derive(Clone)]
struct State {
  db: mongodb::Database,
}
Enter fullscreen mode Exit fullscreen mode

Now we need to update the main function. We have to create the database connection and add it in the state before creating the Tide app.

Here is the updated main function

#[async_std::main]
async fn main() -> tide::Result<()> {
  // Use the dotenv crate to read the .env file and add the environment variables
  dotenv().ok();

  // Create the MongoDB client options with the connection string from the environment variables
  let mongodb_client_options =
    mongodb::options::ClientOptions::parse(&env::var("MONGODB_URI").unwrap()).await?;

  // Instantiate the MongoDB client
  let mongodb_client = mongodb::Client::with_options(mongodb_client_options)?;

  // Get a handle to the "rust-api-example" database
  let db = mongodb_client.database("rust-api-example");

  // Create the Tide state with the database connection
  let state = State { db };

  let mut app = tide::with_state(state);

  app.at("/hello").get(hello);

  app.listen("127.0.0.1:8080").await?;

  return Ok(());
}
Enter fullscreen mode Exit fullscreen mode

I've added comments in the code to help you understand what's going on.

I first called the dotenv().ok() method to read from the .env file and add the environment variables. Then I created a connection to the database. Finally, I passed that connection to the Tide State so that our controllers have access to it.

We will now create two controllers. One to create documents in the database and one to retrieve those documents.

Here is the code of the first controller

#[derive(Debug, Serialize, Deserialize)]
// The request's body structure
pub struct Item {
  pub name: String,
  pub price: f32,
}

async fn post_item(mut req: Request<State>) -> tide::Result {
  // Read the request's body and transform it into a struct
  let item = req.body_json::<Item>().await?;

  // Recover the database connection handle from the Tide state
  let db = &req.state().db;

  // Get a handle to the "items" collection
  let items_collection = db.collection_with_type::<Item>("items");

  // Insert a new Item in the "items" collection using values
  // from the request's body
  items_collection
    .insert_one(
      Item {
        name: item.name,
        price: item.price,
      },
      None,
    )
    .await?;

  // Return 200 if everything went fine
  return Ok(Response::new(StatusCode::Ok));
}
Enter fullscreen mode Exit fullscreen mode

First I've defined an Item struct that represents the request's body. The body should be a JSON object with a property name of type string and a property price of type number.

Inside the function, I first try to read the request's body. I did not do any validation. In a real case, don't be like me and always validate the request's body before using it.

I recover the database connection from the Tide State. Then I get a handle to the items collection.

Finally, I attempt to insert a new document in the collection using the request's body values.

If everything goes smoothly, I return a HTTP status code 200.

Now let's create the second controller to retrieve documents from the items collection, here is the code

async fn get_items(req: Request<State>) -> tide::Result<tide::Body> {
  // Recover the database connection handle from the Tide state
  let db = &req.state().db;

  // Get a handle to the "items" collection
  let items_collection = db.collection_with_type::<Item>("items");

  // Find all the documents from the "items" collection
  let mut cursor = items_collection.find(None, None).await?;

  // Create a new empty Vector of Item
  let mut data = Vec::<Item>::new();

  // Loop through the results of the find query
  while let Some(result) = cursor.next().await {
    // If the result is ok, add the Item in the Vector
    if let Ok(item) = result {
      data.push(item);
    }
  }

  // Send the response with the list of items
  return Body::from_json(&data);
}
Enter fullscreen mode Exit fullscreen mode

Inside the function I retrieve the handle to the database connection from the Tide State, then I get a handle to the items collection.

I use the find function to retrieve all the documents from the items collection. After that I create a new empty Vector of Item.

I loop through the find query results and I insert each item in the Vector.

Finally, I send the response with the list of items in the body.

The controllers are ready but we still need to add them in the main function. Add the following after the hello route in the main function

app.at("/items").get(get_items);
app.at("/items").post(post_item);
Enter fullscreen mode Exit fullscreen mode

Voilà! Let's test it out. Start the server with the following command

cargo run
Enter fullscreen mode Exit fullscreen mode

You should now be able to send a POST request to /items (http://localhost:8080/items). Here is an example of the body (make sure to set the Content-Type header to application/json)

{
  "name": "coffee",
  "price": 2.5
}
Enter fullscreen mode Exit fullscreen mode

You should get an empty response with status code 200.

Then try to retrieve the document you just created by doing a GET request on /items (http://localhost:8080/items).

You should receive an array with a single entry being the item we just created

[
  {
    "name": "coffee",
    "price": 2.5
  }
]
Enter fullscreen mode Exit fullscreen mode

In conclusion

That's it for the part 2 of this guide, I hope it was helpful. If you noticed any error or something that could be improved, please let me know!

You can find the the full example on GitHub: https://github.com/ncribt/rust-api-example-part-2

In the next and last part of the guide, I will show you how to protect the POST /items endpoint using a JWT (JSON Web Token) and a middleware.

Discover more posts from me on my personal blog: https://naruhodo.dev

Discussion (0)