Intro
When working on one of my projects I decided to create simple logging API and Rust seemed like a perfect choice to learn some new tech. Same goes for going with Azure CosmosDB which now offer free tier that is perfect for learning and small personal projects.
I consider this tutorial to be a good starting point for beginner rustaceans (I'm one of those) but I assume that you know the basics. I highly recommend going through official rustlings tutorial.
"Final" code can be found on my github repo
Setting up "Hello fellow Rustacean!"
First let's create a new projct by either creating new project directory and running cargo init
from it or using cargo new {project-name}
that also create directory for you. Once you're ready open your editor of choice (VS Code here with official 'Rust' extensions and 'cargo' that helps with staying up do date with dependencies) and let's start!
We will begin by creating simple http server that return us classic greatings. Open Cargo.toml
and add two new dependencies:
[dependencies]
actix-rt = "1.1.1"
actix-web = "2.0"
Note: First stick with versions I used for this tutorial and then update. I found that already few crates had some breaking changes so it's safer to application working first.
Then replace code in main.rs with the one below:
use actix_web::{web, App, HttpServer, Responder};
use std::env;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
env::set_var("RUST_LOG", "actix_web=debug");
HttpServer::new(|| App::new().route("/", web::get().to(hello)))
.bind("127.0.0.1:8000")?
.run()
.await
}
async fn hello() -> impl Responder {
format!("Hello fellow Rustacean!")
}
That's it! now just cargo run
and go to 127.0.0.1:8000
in your browser.
Let's quickly see what we did here:
-
#[actix_rt::main]
marked our main async function as to be executed in actix runtime. -
"RUST_LOG"
sets logger used by actix to output errors. - New
App
with registered request handler is passed toHttpServer
to listen for incoming connections.
Creating service configuration
As you can see, right now we've registered our routes in main function. In this tutorial we won't have many resources but it's good practice to have cleaner structure. Create new file: src\logs_handlers\mod.rs
and add code below:
use actix_web::{web, Responder};
pub fn scoped_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/logs")
.route(web::get().to(get_logs))
.route(web::post().to(add_log)),
);
}
async fn get_logs() -> impl Responder {
format!("Not yet implemented!")
}
async fn add_log() -> impl Responder {
format!("Not yet implemented!")
}
The scoped_config()
function is responsible for registering logs resource configuration in our service. That means we can create multiple modules for each resource and then just call this configurator function for each of them from our main
function. So, let's do that by modifying App
builder code:
App::new().service(web::scope("/api").configure(logs_handlers::scoped_config))
Don't forget to import newly created module as well and add
mod logs_handlers;
below the use
block.
Now we've set up our api to handle GET and POST methods on route api/logs
. Try it!
Connecting to CosmosDB/MongoDB
In this tutorial I use free tier Azure CosmosDB database with MongoDB API, but of course the choice is yours. Let's create db client and return some data. When I was prototyping my API, I was creating client in the handler manually. That is really inefficient way, instead we can utilize client pooling build in MongoDB client crate and setup it on app startup. Add MongoDB dependency to Cargo.toml
bson = "1.0.0"
futures = "0.3.5"
mongodb = "1.0.0"
Open main.rs
and add import module:
use mongodb::{options::ClientOptions, Client};
use std::sync::*;
And then modify your main function to look like this:
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
env::set_var("RUST_LOG", "actix_web=debug");
let mut client_options = ClientOptions::parse("mongodb://free-tier-db:yfQtNbXyW2h9HUOOplCeHgjzzbJMnfMQn2BZuzAkw5gv0uBkqbdbQPdnQ98e6UtS5Z3p1ZrG4rgkmEKBURNgwg==@free-tier-db.mongo.cosmos.azure.com:10255/?ssl=true&replicaSet=globaldb&retrywrites=false&maxIdleTimeMS=120000&appName=@free-tier-db@").await.unwrap();
client_options.app_name = Some("PlantApi".to_string());
let client = web::Data::new(Mutex::new(Client::with_options(client_options).unwrap()));
HttpServer::new(move || {
App::new()
.app_data(client.clone())
.service(web::scope("/api").configure(logs_handlers::scoped_config))
})
.bind("127.0.0.1:8000")?
.run()
.await
}
Note: Replace connection string with yours. Don't worry we will not leave connection string in the code in the final version.
Our new code creates a MongoDB client that is wrapped in mutex object for thread safety and which in turn is passed to application data object that is responsible for making it available in handlers.
Fetching logs
Now we are ready to connect to our DB and make use of it. Go to logs_handlers/mod.rs
and import few more modules:
use actix_web::{web, HttpResponse, Responder};
use bson::{doc, Bson};
use futures::stream::StreamExt;
use mongodb::{options::FindOptions, Client};
use std::sync::Mutex;
const MONGO_DB: &'static str = "iotPlantDB";
const MONGO_COLL_LOGS: &'static str = "logs";
...// no changes in scoped_config
async fn get_logs(data: web::Data<Mutex<Client>>) -> impl Responder {
let logs_collection = data
.lock()
.unwrap()
.database(MONGO_DB)
.collection(MONGO_COLL_LOGS);
let filter = doc! {};
let find_options = FindOptions::builder().sort(doc! { "_id": -1}).build();
let mut cursor = logs_collection.find(filter, find_options).await.unwrap();
let mut results = Vec::new();
while let Some(result) = cursor.next().await {
match result {
Ok(document) => {
results.push(document);
}
_ => {
return HttpResponse::InternalServerError().finish();
}
}
}
HttpResponse::Ok().json(results)
}
... // no changes in add_log
Ok, let's see what we did here.
- We created two constants to hold our DB and collection names.
- Our
get_logs
function accept application data (our MongoDB client that we've set up inmain
function). - We use client to give us handle to the logs collection.
-
find
function is used with no filter (returns everything) and simple sort by _id - We iterate results using
cursor
returned byfind
and populate result vector with incoming documents which then is returned in json format.
Now make sure you have some data in your DB and you're ready to test first call. My test data, and the one I will be using in add_log
handler looks like this:
{
"_id": {
"$oid": "5ee3bb1f00bc6d3b007b79ca"
},
"deviceId": "mock_device-01",
"message": "test message",
"createdOn": {
"$date": "2020-06-12T17:27:59.404Z"
}
}
Before we move to implementing POST handler let's do one more change. We should move the connection string out from the code. Let's save it as environmental variable named CONNECTION_STRING_LOGS
and then we can replace line responsible for creating client options in main.rs
with this:
let mongo_url = env::var("CONNECTION_STRING_LOGS").unwrap();
let mut client_options = ClientOptions::parse(&mongo_url).await.unwrap();
Much nicer solution!
Adding logs
It's time to finish our api with the add_log
handler. Add two more dependencies, chrono
and serde
, first will help us with DateTime and latter is the most popular serialize/deserialize crate. Your final dependency list should look like this:
[dependencies]
actix-rt = "1.1.1"
actix-web = "2.0"
bson = "1.0.0"
chrono = "0.4.11"
futures = "0.3.5"
mongodb = "1.0.0"
serde = { version = "1.0", features = ["derive"] }
Let's go back to logs_handlers/mod.rs
, import newly added modules and add struct for new logs:
use chrono::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewLog {
pub id: String,
pub message: String,
}
NewLog
has derive(Deserialize)
trait as this will be used to deserialize incoming POST body. We only need device/source id and a message to log, timestamp will be created in the handler function and MongoDB object id by the database.
Replace add_log
function with:
async fn add_log(data: web::Data<Mutex<Client>>, new_log: web::Json<NewLog>) -> impl Responder {
let logs_collection = data
.lock()
.unwrap()
.database(MONGO_DB)
.collection(MONGO_COLL_LOGS);
match logs_collection.insert_one(doc! {"deviceId": &new_log.id, "message": &new_log.message, "createdOn": Bson::DateTime(Utc::now())}, None).await {
Ok(db_result) => {
if let Some(new_id) = db_result.inserted_id.as_object_id() {
println!("New document inserted with id {}", new_id);
}
return HttpResponse::Created().json(db_result.inserted_id)
}
Err(err) =>
{
println!("Failed! {}", err);
return HttpResponse::InternalServerError().finish()
}
}
}
As with get_logs
this function makes use of mongo client stored in application data to get handle to the logs collection. Notice additional parameter new_log
of type NewLog
that we've created before. If your body match the struct (names and value types) it will be properly deserialized and ready to use. What this function does is:
- Dynamically creates document using
new_log
data and fillcreatedOn
with current UTC date and time. - Check the results and returns new document id if success.
And we're done! Two simple functions that can handle incoming GET and POST requests. Run the application and test it by adding new logs:
curl --location --request POST 'localhost:8000/api/logs' \
--header 'Content-Type: application/json' \
--data-raw '{
"id":"tutorial-client",
"message":"I'\''m a Rustacean!"
}'
Optional get_logs
code
This is a simple example so our get_logs
function returns documents in the format it receives them. But what if we want to perform some operations on the results before we return them? We can easily deserialize document (and check if it matches our model) by modifying code slightly:
We add few more imports:
use bson::{doc, oid::ObjectId, Bson, UtcDateTime};
use serde::{Deserialize, Serialize};
Specify Log
structure as it wil be different than NewLog
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Log {
#[serde(rename = "_id")]
pub id: ObjectId,
#[serde(rename = "deviceId")]
pub device_id: String,
pub message: String,
pub timestamp: UtcDateTime,
}
And just above the line where we push received document into the results vector, deserialize it using model above:
let log: Log = bson::from_bson(Bson::Document(document)).unwrap();
Conclusion
We arrived at the end of this tutorial. As you can see it's not that complicated to create APIs in Rust. Of course, the example above is quite simple, but I think it's a good starting point even if you're not that familiar with Rust.
Please leave a comment if you liked (or not) this article or if something doesn't seem right etc.
Thanks for reading and till the next time!
Top comments (5)
Great tutorial!
Thank very much you!
Excellent post man! I like this so I liked this (and unicorn'ed it haha). 👍
Seems to be basic enough to try it (Im is a rust beginner) ... when I have couple of free time, markded :)
Good to hear :) I tried to keep it as simple as possible. I'm rust beginner as well, coming from C#, and I found few Rust concepts more challenging to get good grasp on.